All transactional/worker senders built multipart/alternative (or mixed) messages with ONLY an HTML part. A single-part multipart/alternative is malformed and HTML-only mail is a spam-score signal -- the same class of deliverability bug that hurt the campaign pipeline, but on the telecom / filing / customer-transactional path (499-Q reminders, RMD/FCC filing review links, intake/completion/delivery emails, commissions, etc). - worker_email.send_worker_email: auto-derive plaintext from HTML when caller omits text= (fixes the shared helper for all current+future use) - 16 rolled-their-own senders in scripts/workers/** + scripts/formation/ document_delivery.py: attach html_to_text(...) plaintext sibling before the HTML part (job_server + document_delivery wrap text+html in an alternative sub-part so PDFs still attach to the mixed root) - api/src/email.ts: add dependency-free htmlToText() and default sendEmail text to it (fixes checkout/webhook HTML-only sends) Verified: all py files compile + import at runtime, api tsc passes, htmlToText handles hrefs/lists/entities, 11 plaintext unit tests pass. Telecom campaign 407 (Jun 8) was HTML-only + sent in the DKIM-broken window -> 384 sent / 0 clicks (same junked-mail signature).
231 lines
7.8 KiB
Python
231 lines
7.8 KiB
Python
"""Generic eSign helper — create signing records and send signing links.
|
|
|
|
Usage from any service handler:
|
|
|
|
from scripts.workers.services.telecom.esign_helper import request_esign
|
|
|
|
request_esign(
|
|
conn=conn,
|
|
order_number="CO-ABC12345",
|
|
document_type="rmd",
|
|
document_title="RMD Certification Letter",
|
|
entity_name="Acme Telecom LLC",
|
|
customer_email="john@example.com",
|
|
customer_name="John Smith",
|
|
document_minio_key="compliance/CO-ABC12345/rmd_letter.pdf",
|
|
requires_perjury=True,
|
|
metadata={"frn": "0015341902"},
|
|
)
|
|
|
|
This will:
|
|
1. INSERT a row into esign_records (status = 'pending')
|
|
2. Generate a JWT portal token
|
|
3. Send a signing email with the portal link
|
|
"""
|
|
import logging
|
|
import os
|
|
import smtplib
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
|
|
from scripts._email_plaintext import html_to_text
|
|
|
|
logger = logging.getLogger("esign_helper")
|
|
|
|
|
|
def request_esign(
|
|
conn,
|
|
order_number: str,
|
|
document_type: str,
|
|
document_title: str,
|
|
entity_name: str,
|
|
customer_email: str,
|
|
customer_name: str = "",
|
|
document_minio_key: str = "",
|
|
requires_perjury: bool = False,
|
|
metadata: dict | None = None,
|
|
expires_hours: int = 72,
|
|
) -> int | None:
|
|
"""Create an esign record and email the signing link.
|
|
|
|
Returns the esign_records.id on success, None on failure.
|
|
"""
|
|
import json
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
try:
|
|
import jwt as pyjwt
|
|
except ImportError:
|
|
try:
|
|
import PyJWT as pyjwt # type: ignore
|
|
except ImportError:
|
|
logger.error("No JWT library available — cannot create esign link")
|
|
return None
|
|
|
|
secret = os.environ.get("CUSTOMER_JWT_SECRET", "changeme")
|
|
domain = os.environ.get("DOMAIN", "performancewest.net")
|
|
expires_at = datetime.now(timezone.utc) + timedelta(hours=expires_hours)
|
|
|
|
# 1. Upsert into esign_records (replace any existing pending record)
|
|
try:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""INSERT INTO esign_records
|
|
(order_number, document_type, document_title, entity_name,
|
|
document_minio_key, document_metadata, requires_perjury,
|
|
status, expires_at)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, 'pending', %s)
|
|
ON CONFLICT (order_number, document_type)
|
|
WHERE status IN ('pending', 'signed')
|
|
DO UPDATE SET
|
|
document_title = EXCLUDED.document_title,
|
|
entity_name = EXCLUDED.entity_name,
|
|
document_minio_key = EXCLUDED.document_minio_key,
|
|
document_metadata = EXCLUDED.document_metadata,
|
|
requires_perjury = EXCLUDED.requires_perjury,
|
|
expires_at = EXCLUDED.expires_at,
|
|
updated_at = NOW()
|
|
RETURNING id""",
|
|
(
|
|
order_number,
|
|
document_type,
|
|
document_title,
|
|
entity_name,
|
|
document_minio_key,
|
|
json.dumps(metadata or {}),
|
|
requires_perjury,
|
|
expires_at,
|
|
),
|
|
)
|
|
row = cur.fetchone()
|
|
esign_id = row[0] if row else None
|
|
conn.commit()
|
|
except Exception as exc:
|
|
logger.error("Failed to insert esign record for %s: %s", order_number, exc)
|
|
conn.rollback()
|
|
return None
|
|
|
|
# 2. Generate JWT portal token
|
|
token = pyjwt.encode(
|
|
{
|
|
"order_id": order_number,
|
|
"order_type": document_type,
|
|
"email": customer_email,
|
|
},
|
|
secret,
|
|
algorithm="HS256",
|
|
)
|
|
sign_url = f"https://{domain}/portal/esign/?token={token}"
|
|
|
|
# 3. Send signing email
|
|
try:
|
|
_send_signing_email(
|
|
to_email=customer_email,
|
|
to_name=customer_name or entity_name,
|
|
entity_name=entity_name,
|
|
document_title=document_title,
|
|
sign_url=sign_url,
|
|
order_number=order_number,
|
|
)
|
|
logger.info(
|
|
"eSign link sent to %s for %s (%s)",
|
|
customer_email, order_number, document_type,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("Could not send eSign email for %s: %s", order_number, exc)
|
|
# Non-fatal — record exists, admin can resend
|
|
|
|
return esign_id
|
|
|
|
|
|
def _send_signing_email(
|
|
to_email: str,
|
|
to_name: str,
|
|
entity_name: str,
|
|
document_title: str,
|
|
sign_url: str,
|
|
order_number: str,
|
|
) -> None:
|
|
"""Send the signing invitation email."""
|
|
smtp_host = os.environ.get("SMTP_HOST", "co.carrierone.com")
|
|
smtp_port = int(os.environ.get("SMTP_PORT", "587"))
|
|
smtp_user = os.environ.get("SMTP_USER", "")
|
|
smtp_pass = os.environ.get("SMTP_PASS", "")
|
|
|
|
if not smtp_user or not smtp_pass:
|
|
logger.warning("SMTP credentials not configured — skipping eSign email")
|
|
return
|
|
|
|
subject = f"Action Required: Sign Your {document_title}"
|
|
|
|
body = f"""\
|
|
<div style="font-family:'Inter',system-ui,sans-serif;max-width:600px;margin:0 auto;color:#1f2937">
|
|
<div style="background:#1e3a5f;color:#fff;padding:24px 28px;border-radius:12px 12px 0 0">
|
|
<h1 style="margin:0;font-size:20px;font-weight:700">Document Ready for Signature</h1>
|
|
<p style="margin:6px 0 0;opacity:.8;font-size:14px">{entity_name}</p>
|
|
</div>
|
|
|
|
<div style="background:#fff;border:1px solid #e2e8f0;border-top:none;padding:28px;border-radius:0 0 12px 12px">
|
|
<p style="margin:0 0 16px;font-size:15px">Hi {to_name},</p>
|
|
|
|
<p style="margin:0 0 16px;font-size:15px">
|
|
Your <strong>{document_title}</strong> is ready for review and signature.
|
|
Please click the button below to review the document and provide your electronic signature.
|
|
</p>
|
|
|
|
<div style="text-align:center;margin:28px 0">
|
|
<a href="{sign_url}"
|
|
style="display:inline-block;background:#1e3a5f;color:#fff;padding:14px 36px;
|
|
border-radius:10px;font-weight:700;font-size:16px;text-decoration:none">
|
|
Review & Sign Document
|
|
</a>
|
|
</div>
|
|
|
|
<p style="margin:0 0 12px;font-size:13px;color:#64748b">
|
|
This link expires in 72 hours. If it expires, contact us and we'll send a new one.
|
|
</p>
|
|
|
|
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0">
|
|
|
|
<p style="margin:0;font-size:12px;color:#9ca3af">
|
|
Performance West Inc. — (888) 411-0383<br>
|
|
Order: {order_number}
|
|
</p>
|
|
</div>
|
|
</div>"""
|
|
|
|
msg = MIMEMultipart("alternative")
|
|
msg["From"] = "Performance West <noreply@performancewest.net>"
|
|
msg["To"] = f"{to_name} <{to_email}>"
|
|
msg["Subject"] = subject
|
|
msg["Reply-To"] = "info@performancewest.net"
|
|
msg.attach(MIMEText(html_to_text(body), "plain"))
|
|
msg.attach(MIMEText(body, "html"))
|
|
|
|
with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server:
|
|
server.starttls()
|
|
server.login(smtp_user, smtp_pass)
|
|
server.send_message(msg)
|
|
|
|
|
|
def check_esign_status(conn, order_number: str, document_type: str) -> dict | None:
|
|
"""Check if a document has been signed. Returns the record dict or None."""
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""SELECT id, status, signed_at, signer_email, signature_type
|
|
FROM esign_records
|
|
WHERE order_number = %s AND document_type = %s
|
|
AND status IN ('pending', 'signed')
|
|
ORDER BY created_at DESC LIMIT 1""",
|
|
(order_number, document_type),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return None
|
|
return {
|
|
"id": row[0],
|
|
"status": row[1],
|
|
"signed_at": row[2],
|
|
"signer_email": row[3],
|
|
"signature_type": row[4],
|
|
}
|