Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
444 lines
14 KiB
Python
444 lines
14 KiB
Python
"""
|
|
document_delivery.py — Email formation documents to customers.
|
|
|
|
Sends a professional HTML email with attached formation documents
|
|
(Articles of Organization, EIN letter, operating agreement, etc.)
|
|
and updates the order status to 'delivered'.
|
|
|
|
Environment variables:
|
|
DATABASE_URL PostgreSQL connection string
|
|
SMTP_HOST SMTP server hostname
|
|
SMTP_PORT SMTP server port (default: 587)
|
|
SMTP_USER SMTP username / from address
|
|
SMTP_PASS SMTP password
|
|
|
|
Usage:
|
|
python -m formation.document_delivery <order_id>
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import email.mime.application
|
|
import email.mime.multipart
|
|
import email.mime.text
|
|
import json
|
|
import logging
|
|
import mimetypes
|
|
import os
|
|
import smtplib
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import psycopg2
|
|
import psycopg2.extras
|
|
|
|
from .states import STATES
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
DATABASE_URL = os.environ.get("DATABASE_URL", "")
|
|
SMTP_HOST = os.environ.get("SMTP_HOST", "")
|
|
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
|
|
SMTP_USER = os.environ.get("SMTP_USER", "")
|
|
SMTP_PASS = os.environ.get("SMTP_PASS", "")
|
|
FROM_NAME = "Performance West"
|
|
FROM_EMAIL = SMTP_USER or "formations@performancewest.net"
|
|
|
|
LOG = logging.getLogger("formation.delivery")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Email template
|
|
# ---------------------------------------------------------------------------
|
|
|
|
EMAIL_HTML_TEMPLATE = """\
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Your Business Has Been Filed</title>
|
|
</head>
|
|
<body style="margin:0; padding:0; background-color:#f4f4f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7; padding: 40px 0;">
|
|
<tr><td align="center">
|
|
|
|
<!-- Header -->
|
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#1a1a2e; border-radius:8px 8px 0 0; padding:30px 40px;">
|
|
<tr><td>
|
|
<h1 style="color:#ffffff; margin:0; font-size:24px; font-weight:600;">Performance West</h1>
|
|
<p style="color:#a0a0c0; margin:5px 0 0; font-size:14px;">Business Formation Services</p>
|
|
</td></tr>
|
|
</table>
|
|
|
|
<!-- Body -->
|
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff; padding:40px;">
|
|
<tr><td>
|
|
|
|
<p style="font-size:16px; color:#333;">Dear {customer_name},</p>
|
|
|
|
<p style="font-size:16px; color:#333;">
|
|
Great news — your <strong>{entity_type}</strong> has been successfully
|
|
filed with the state of <strong>{state_name}</strong>.
|
|
</p>
|
|
|
|
<!-- Filing details box -->
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f0f4ff; border-radius:8px; padding:24px; margin:24px 0;">
|
|
<tr><td>
|
|
<table width="100%" cellpadding="4" cellspacing="0">
|
|
<tr>
|
|
<td style="font-size:14px; color:#666; width:180px;">Entity Name</td>
|
|
<td style="font-size:14px; color:#1a1a2e; font-weight:600;">{entity_name}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-size:14px; color:#666;">State</td>
|
|
<td style="font-size:14px; color:#1a1a2e; font-weight:600;">{state_name}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-size:14px; color:#666;">Filing Number</td>
|
|
<td style="font-size:14px; color:#1a1a2e; font-weight:600;">{filing_number}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-size:14px; color:#666;">Confirmation Number</td>
|
|
<td style="font-size:14px; color:#1a1a2e; font-weight:600;">{confirmation_number}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-size:14px; color:#666;">Filed Date</td>
|
|
<td style="font-size:14px; color:#1a1a2e; font-weight:600;">{filed_date}</td>
|
|
</tr>
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
|
|
<p style="font-size:16px; color:#333;">
|
|
Your formation documents are attached to this email.
|
|
</p>
|
|
|
|
<!-- Next steps -->
|
|
<h2 style="font-size:18px; color:#1a1a2e; margin:32px 0 16px;">Recommended Next Steps</h2>
|
|
<table width="100%" cellpadding="0" cellspacing="0">
|
|
<tr>
|
|
<td style="padding:8px 0; font-size:15px; color:#333;">
|
|
<strong>1. Obtain an EIN</strong> — Apply for an Employer Identification Number
|
|
from the IRS. This is required to open a business bank account and file taxes.
|
|
{ein_note}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:8px 0; font-size:15px; color:#333;">
|
|
<strong>2. Operating Agreement</strong> — Prepare and sign an operating agreement
|
|
for your {entity_type}. This document outlines ownership, management structure,
|
|
and member responsibilities.
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:8px 0; font-size:15px; color:#333;">
|
|
<strong>3. Open a Business Bank Account</strong> — Keep personal and business
|
|
finances separate. You'll need your Articles of Organization, EIN, and
|
|
operating agreement.
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:8px 0; font-size:15px; color:#333;">
|
|
<strong>4. Business Licenses & Permits</strong> — Check your local
|
|
city/county requirements for any additional licenses or permits.
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:8px 0; font-size:15px; color:#333;">
|
|
<strong>5. Annual Reports</strong> — Most states require an annual or biennial
|
|
report. We'll send you a reminder when yours is due.
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<hr style="border:none; border-top:1px solid #e0e0e0; margin:32px 0;" />
|
|
|
|
<p style="font-size:14px; color:#666;">
|
|
If you have any questions about your filing or need additional services,
|
|
don't hesitate to reach out.
|
|
</p>
|
|
|
|
</td></tr>
|
|
</table>
|
|
|
|
<!-- Footer -->
|
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7; padding:24px 40px;">
|
|
<tr><td>
|
|
<p style="font-size:13px; color:#999; margin:0;">
|
|
Performance West · Business Formation & Compliance Services
|
|
</p>
|
|
<p style="font-size:13px; color:#999; margin:4px 0 0;">
|
|
Email: formations@performancewest.net · Phone: (307) 316-5620
|
|
</p>
|
|
<p style="font-size:12px; color:#bbb; margin:12px 0 0;">
|
|
This email and any attachments are intended solely for the named recipient.
|
|
If you received this in error, please delete it and notify the sender.
|
|
</p>
|
|
</td></tr>
|
|
</table>
|
|
|
|
</td></tr>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Database helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_connection():
|
|
if not DATABASE_URL:
|
|
raise RuntimeError("DATABASE_URL environment variable is not set.")
|
|
return psycopg2.connect(DATABASE_URL)
|
|
|
|
|
|
def _fetch_order(conn, order_id: str) -> dict | None:
|
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
cur.execute("SELECT * FROM formation_orders WHERE order_id = %s", (order_id,))
|
|
row = cur.fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def _mark_delivered(conn, order_id: str):
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
"""
|
|
UPDATE formation_orders
|
|
SET status = 'delivered',
|
|
delivered_at = NOW(),
|
|
updated_at = NOW()
|
|
WHERE order_id = %s
|
|
""",
|
|
(order_id,),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Email sending
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_email(
|
|
customer_email: str,
|
|
customer_name: str,
|
|
entity_name: str,
|
|
entity_type: str,
|
|
state_code: str,
|
|
filing_number: str,
|
|
confirmation_number: str,
|
|
filed_date: str,
|
|
documents: list[str],
|
|
ein: str = "",
|
|
) -> email.mime.multipart.MIMEMultipart:
|
|
"""Build the MIME email with HTML body and document attachments."""
|
|
state_name = STATES.get(state_code.upper(), {}).get("name", state_code)
|
|
|
|
# Entity type display name
|
|
type_display = {
|
|
"llc": "LLC",
|
|
"corporation": "Corporation",
|
|
"s_corp": "S Corporation",
|
|
}.get(entity_type.lower(), entity_type)
|
|
|
|
ein_note = ""
|
|
if ein:
|
|
ein_note = f"<br/><em>Your EIN ({ein}) has already been obtained and is included in your documents.</em>"
|
|
|
|
html_body = EMAIL_HTML_TEMPLATE.format(
|
|
customer_name=customer_name,
|
|
entity_type=type_display,
|
|
entity_name=entity_name,
|
|
state_name=state_name,
|
|
filing_number=filing_number or "Pending",
|
|
confirmation_number=confirmation_number or "N/A",
|
|
filed_date=filed_date or "N/A",
|
|
ein_note=ein_note,
|
|
)
|
|
|
|
msg = email.mime.multipart.MIMEMultipart("mixed")
|
|
msg["From"] = f"{FROM_NAME} <{FROM_EMAIL}>"
|
|
msg["To"] = customer_email
|
|
msg["Subject"] = f"Your {type_display} Has Been Filed — {entity_name}"
|
|
msg["Reply-To"] = FROM_EMAIL
|
|
|
|
# HTML body
|
|
html_part = email.mime.text.MIMEText(html_body, "html", "utf-8")
|
|
msg.attach(html_part)
|
|
|
|
# Attach documents
|
|
for doc_path in documents:
|
|
path = Path(doc_path)
|
|
if not path.exists():
|
|
LOG.warning("Document not found, skipping: %s", doc_path)
|
|
continue
|
|
|
|
content_type, _ = mimetypes.guess_type(str(path))
|
|
if content_type is None:
|
|
content_type = "application/octet-stream"
|
|
maintype, subtype = content_type.split("/", 1)
|
|
|
|
with open(path, "rb") as f:
|
|
attachment = email.mime.application.MIMEApplication(f.read(), _subtype=subtype)
|
|
attachment.add_header(
|
|
"Content-Disposition",
|
|
"attachment",
|
|
filename=path.name,
|
|
)
|
|
msg.attach(attachment)
|
|
LOG.info("Attached: %s (%s)", path.name, content_type)
|
|
|
|
return msg
|
|
|
|
|
|
def send_delivery_email(
|
|
order_id: str,
|
|
customer_email: str,
|
|
customer_name: str,
|
|
documents: list[str],
|
|
) -> bool:
|
|
"""
|
|
Send formation documents to a customer and update order status.
|
|
|
|
Args:
|
|
order_id: The formation order ID.
|
|
customer_email: Customer's email address.
|
|
customer_name: Customer's display name.
|
|
documents: List of file paths to attach.
|
|
|
|
Returns:
|
|
True if email sent successfully, False otherwise.
|
|
"""
|
|
if not SMTP_HOST:
|
|
LOG.error("SMTP_HOST not configured — cannot send email.")
|
|
return False
|
|
|
|
conn = _get_connection()
|
|
try:
|
|
order = _fetch_order(conn, order_id)
|
|
if not order:
|
|
LOG.error("Order not found: %s", order_id)
|
|
return False
|
|
|
|
entity_name = order.get("entity_name", "")
|
|
entity_type = order.get("entity_type", "llc")
|
|
state_code = order.get("state_code", "")
|
|
filing_number = order.get("filing_number", "")
|
|
confirmation_number = order.get("confirmation_number", "")
|
|
filed_at = order.get("filed_at")
|
|
ein = order.get("ein", "") or ""
|
|
|
|
filed_date = ""
|
|
if filed_at:
|
|
if isinstance(filed_at, str):
|
|
filed_date = filed_at[:10]
|
|
elif isinstance(filed_at, datetime):
|
|
filed_date = filed_at.strftime("%Y-%m-%d")
|
|
|
|
msg = _build_email(
|
|
customer_email=customer_email,
|
|
customer_name=customer_name,
|
|
entity_name=entity_name,
|
|
entity_type=entity_type,
|
|
state_code=state_code,
|
|
filing_number=filing_number,
|
|
confirmation_number=confirmation_number,
|
|
filed_date=filed_date,
|
|
documents=documents,
|
|
ein=ein,
|
|
)
|
|
|
|
LOG.info(
|
|
"Sending delivery email to %s for order %s (%s)...",
|
|
customer_email,
|
|
order_id,
|
|
entity_name,
|
|
)
|
|
|
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as smtp:
|
|
smtp.ehlo()
|
|
if SMTP_PORT != 25:
|
|
smtp.starttls()
|
|
smtp.ehlo()
|
|
if SMTP_USER and SMTP_PASS:
|
|
smtp.login(SMTP_USER, SMTP_PASS)
|
|
smtp.send_message(msg)
|
|
|
|
LOG.info("Email sent successfully to %s", customer_email)
|
|
|
|
# Mark order as delivered
|
|
_mark_delivered(conn, order_id)
|
|
LOG.info("Order %s marked as delivered", order_id)
|
|
return True
|
|
|
|
except smtplib.SMTPException as exc:
|
|
LOG.error("SMTP error sending to %s: %s", customer_email, exc)
|
|
return False
|
|
except Exception as exc:
|
|
LOG.error("Failed to send delivery email: %s", exc, exc_info=True)
|
|
return False
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def main():
|
|
"""CLI entry point: deliver documents for a specific order."""
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
|
)
|
|
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python -m formation.document_delivery <order_id>")
|
|
print()
|
|
print("Fetches order details from the database, builds a delivery email,")
|
|
print("and sends it with attached documents.")
|
|
sys.exit(1)
|
|
|
|
order_id = sys.argv[1]
|
|
|
|
if not DATABASE_URL:
|
|
print("Error: DATABASE_URL not set.", file=sys.stderr)
|
|
sys.exit(1)
|
|
if not SMTP_HOST:
|
|
print("Error: SMTP_HOST not set.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
conn = _get_connection()
|
|
try:
|
|
order = _fetch_order(conn, order_id)
|
|
if not order:
|
|
print(f"Error: Order {order_id} not found.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
customer_email = order.get("customer_email", "")
|
|
customer_name = order.get("customer_name", "")
|
|
|
|
if not customer_email:
|
|
print(f"Error: No customer_email on order {order_id}.", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Gather document paths
|
|
docs_raw = order.get("documents")
|
|
if isinstance(docs_raw, str):
|
|
docs_raw = json.loads(docs_raw)
|
|
documents = docs_raw or []
|
|
finally:
|
|
conn.close()
|
|
|
|
success = send_delivery_email(order_id, customer_email, customer_name, documents)
|
|
sys.exit(0 if success else 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|