- analysis.py: deterministic claim scorer + /analyze /approve /letter /advance-tier endpoints (auto-runs on intake) - packet.py + packet_fields.json: welcome-packet templating engine (6 onboarding docs, field catalog) - letterstream.py + letters.py: certified-mail send pipeline + letter lifecycle (webhook verified) - docuseal.py: DocuSeal signing integration - staff.py/models.py/schema.sql/auth.py: approval actor from staff key, tier gate (APPROVED+ACTIVE+onboarding docs), onboarding_docs table - frontend/: dependency-free static portal (intake, magic-link login/verify, dashboard) - landing-mockups/: 4 design-stance mockups + favicons - legal/: aup/privacy/sms-terms/terms HTML - docs/: letter-queue scope, letterstream API contract, 6 welcome-packet templates - review-dre-landing-2026-08-21.md: 3-variant landing feedback sprint - compliance/DRE_Compliance_Manual.md: updated Source synced from deployed /opt/dre-portal/app/ (was 4 days ahead of git).
251 lines
8.7 KiB
Python
251 lines
8.7 KiB
Python
"""LetterStream mail-fulfillment integration.
|
|
|
|
Send endpoint: POST https://www.letterstream.com/apis/ (form-encoded or multipart).
|
|
Auth (verified live 2026-08-25):
|
|
t = unique numeric id (10-18 digits), unique per request
|
|
s = t[-6:] + api_key + t[:6]
|
|
h = md5(base64_encode(s))
|
|
Response: XML <messages><message type><code><details>...</messages>.
|
|
|
|
Key codes: -100 success, -199 AUTHOK, -200 preauth success, -911 insufficient
|
|
funding, -950 auth fail, -957 DUP, -958 IDOK, -998 improper submission, -999 error.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import os
|
|
import time
|
|
import uuid
|
|
import xml.etree.ElementTree as ET
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
ENDPOINT = "https://www.letterstream.com/apis/"
|
|
API_ID = os.environ.get("LETTERSTREAM_API_ID", "")
|
|
API_KEY = os.environ.get("LETTERSTREAM_API_KEY", "")
|
|
UA = "DRE-integration/1.0"
|
|
|
|
|
|
class LetterStreamError(Exception):
|
|
"""Raised when LetterStream returns an error or is unreachable."""
|
|
|
|
|
|
def auth() -> dict:
|
|
"""Return {'a', 'h', 't'} for a single request. Call once per request."""
|
|
if not API_ID or not API_KEY:
|
|
raise LetterStreamError("LETTERSTREAM_API_ID/KEY not configured")
|
|
t = str(int(time.time() * 1000)) # 13-digit millis, within 10-18 digit spec
|
|
s = t[-6:] + API_KEY + t[:6]
|
|
h = hashlib.md5(base64.b64encode(s.encode())).hexdigest()
|
|
return {"a": API_ID, "h": h, "t": t}
|
|
|
|
|
|
def _multipart(items: list[tuple[str, str]], files: dict) -> tuple[bytes, str]:
|
|
"""Build a multipart/form-data body. files: {field: (filename, bytes, content_type)}."""
|
|
boundary = "----DRE" + uuid.uuid4().hex
|
|
chunks: list[bytes] = []
|
|
|
|
def add(data) -> None:
|
|
chunks.append(data.encode("utf-8") if isinstance(data, str) else data)
|
|
|
|
for k, v in items:
|
|
add(f"--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n")
|
|
add(str(v))
|
|
add("\r\n")
|
|
for k, (filename, data, ctype) in files.items():
|
|
add(f"--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"; "
|
|
f"filename=\"{filename}\"\r\nContent-Type: {ctype}\r\n\r\n")
|
|
add(data)
|
|
add("\r\n")
|
|
add(f"--{boundary}--\r\n")
|
|
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
|
|
|
|
|
|
def _post(items: list[tuple[str, str]], files: dict | None = None,
|
|
timeout: int = 60) -> bytes:
|
|
if files:
|
|
body, ctype = _multipart(items, files)
|
|
else:
|
|
body = urllib.parse.urlencode(items).encode()
|
|
ctype = "application/x-www-form-urlencoded"
|
|
req = urllib.request.Request(
|
|
ENDPOINT, data=body, headers={"Content-Type": ctype, "User-Agent": UA},
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return resp.read()
|
|
except urllib.error.HTTPError as e:
|
|
return e.read()
|
|
except urllib.error.URLError as e:
|
|
raise LetterStreamError(f"LetterStream unreachable: {e.reason}") from e
|
|
|
|
|
|
def _auth_items() -> list[tuple[str, str]]:
|
|
a = auth()
|
|
return [("a", a["a"]), ("h", a["h"]), ("t", a["t"])]
|
|
|
|
|
|
def parse_xml(raw: bytes | str) -> dict:
|
|
"""Parse the XML response into a convenient dict. Streams (PDF) pass through."""
|
|
if isinstance(raw, bytes):
|
|
raw = raw.decode("utf-8", "replace")
|
|
out: dict = {"raw": raw, "messages": []}
|
|
if raw.startswith("%PDF"):
|
|
out["stream"] = "pdf"
|
|
out["data"] = raw
|
|
return out
|
|
try:
|
|
root = ET.fromstring(raw)
|
|
except ET.ParseError:
|
|
out["stream"] = "unknown"
|
|
return out
|
|
out["id"] = root.get("id", "")
|
|
for msg in root.findall("message"):
|
|
m: dict = {"type": msg.get("type", "")}
|
|
for tag in ("code", "details", "batch", "quantity", "cost", "authcode",
|
|
"date", "balance", "testmode", "id", "job"):
|
|
el = msg.find(tag)
|
|
if el is not None and el.text is not None:
|
|
m[tag] = el.text.strip()
|
|
docs = []
|
|
for d in msg.findall("doc"):
|
|
docs.append({c.tag: (c.text or "").strip() for c in d})
|
|
if docs:
|
|
m["docs"] = docs
|
|
out["messages"].append(m)
|
|
return out
|
|
|
|
|
|
def _first(response: dict) -> dict:
|
|
return response["messages"][0] if response.get("messages") else {}
|
|
|
|
|
|
def _raise_on_error(response: dict) -> None:
|
|
"""Raise LetterStreamError if the response is an error message."""
|
|
for m in response.get("messages", []):
|
|
code = m.get("code")
|
|
if m.get("type") == "error" and code not in (None, "-100", "-200"):
|
|
raise LetterStreamError(f"LetterStream {code}: {m.get('details', '')}")
|
|
|
|
|
|
def account_status() -> dict:
|
|
"""Return {'date','id','balance','testmode'}."""
|
|
items = _auth_items() + [("accountstatus", "1")]
|
|
resp = parse_xml(_post(items))
|
|
_raise_on_error(resp)
|
|
for m in resp.get("messages", []):
|
|
if m.get("type") == "accountstatus":
|
|
return {k: m.get(k) for k in ("date", "id", "balance", "testmode")}
|
|
raise LetterStreamError(f"Unexpected account status response: {resp.get('raw', '')[:200]}")
|
|
|
|
|
|
def send_single(pdf_bytes: bytes, filename: str, job: str, sender: str,
|
|
recipients: list[str], pages: int, mailtype: str = "firstclass",
|
|
coversheet: str | None = None, duplex: str | None = None,
|
|
ink: str | None = None, paper: str | None = None,
|
|
returnenv: str | None = None, preauth: bool = False) -> dict:
|
|
"""Submit one PDF to one or more recipients (method 2).
|
|
|
|
sender: 'name_1:name_2:addr_1:addr_2:city:state:zip'
|
|
recipients: list of 'doc_id:name_1:name_2:addr_1:addr_2:city:state:zip'
|
|
"""
|
|
items = _auth_items() + [("job", job), ("from", sender), ("pages", str(pages))]
|
|
for r in recipients:
|
|
items.append(("to[]", r))
|
|
if mailtype:
|
|
items.append(("mailtype", mailtype))
|
|
if coversheet is not None:
|
|
items.append(("coversheet", coversheet))
|
|
if duplex is not None:
|
|
items.append(("duplex", duplex))
|
|
if ink is not None:
|
|
items.append(("ink", ink))
|
|
if paper is not None:
|
|
items.append(("paper", paper))
|
|
if returnenv is not None:
|
|
items.append(("returnenv", returnenv))
|
|
if preauth:
|
|
items.append(("preauth", "1"))
|
|
files = {"single_file": (filename, pdf_bytes, "application/pdf")}
|
|
resp = parse_xml(_post(items, files=files))
|
|
_raise_on_error(resp)
|
|
return resp
|
|
|
|
|
|
def send_batch(zip_bytes: bytes, filename: str) -> dict:
|
|
"""Submit a ZIP archive (PDFs + CSV) via batch method (method 1)."""
|
|
items = _auth_items()
|
|
files = {"multi_file": (filename, zip_bytes, "application/zip")}
|
|
resp = parse_xml(_post(items, files=files))
|
|
_raise_on_error(resp)
|
|
return resp
|
|
|
|
|
|
def doauth(authcode: str) -> dict:
|
|
"""Release a preauth job into production."""
|
|
items = _auth_items() + [("doauth", authcode)]
|
|
resp = parse_xml(_post(items))
|
|
_raise_on_error(resp)
|
|
return resp
|
|
|
|
|
|
def _tracking_query(**kwargs) -> dict:
|
|
items = _auth_items() + [(k, v) for k, v in kwargs.items() if v]
|
|
resp = parse_xml(_post(items))
|
|
_raise_on_error(resp)
|
|
return resp
|
|
|
|
|
|
def tracking(cert: str | None = None, doc_id: str | None = None,
|
|
fmt: str = "xml") -> dict:
|
|
"""Tracking info by certified number or doc_id. fmt: 'html'|'xml'|'json'."""
|
|
args = {}
|
|
if cert:
|
|
args["cert"] = cert
|
|
args["getinfo"] = "trackx" if fmt == "xml" else "track"
|
|
elif doc_id:
|
|
args["doc_id"] = doc_id
|
|
args["getinfo"] = "trackx" if fmt == "xml" else "track"
|
|
else:
|
|
raise LetterStreamError("tracking() requires cert or doc_id")
|
|
if fmt == "json":
|
|
args["responseformat"] = "json"
|
|
return _tracking_query(**args)
|
|
|
|
|
|
def signature(cert: str | None = None, doc_id: str | None = None) -> bytes:
|
|
"""Return the certified signature file as PDF bytes."""
|
|
args = {"getinfo": "sig"}
|
|
if cert:
|
|
args["cert"] = cert
|
|
elif doc_id:
|
|
args["doc_id"] = doc_id
|
|
else:
|
|
raise LetterStreamError("signature() requires cert or doc_id")
|
|
items = _auth_items() + list(args.items())
|
|
raw = _post(items)
|
|
if raw[:4] == b"%PDF":
|
|
return raw
|
|
resp = parse_xml(raw)
|
|
_raise_on_error(resp)
|
|
raise LetterStreamError("No signature file returned")
|
|
|
|
|
|
def proof(doc_id: str) -> bytes:
|
|
"""Return the document proof as PDF bytes (base64-decoded when needed)."""
|
|
items = _auth_items() + [("doc_id", doc_id), ("getinfo", "proof")]
|
|
raw = _post(items)
|
|
if raw[:4] == b"%PDF":
|
|
return raw
|
|
text = raw.decode("utf-8", "replace").strip()
|
|
if len(text) > 10000:
|
|
try:
|
|
return base64.b64decode(text)
|
|
except Exception as e:
|
|
raise LetterStreamError(f"Proof base64 decode failed: {e}") from e
|
|
resp = parse_xml(raw)
|
|
_raise_on_error(resp)
|
|
raise LetterStreamError("No proof returned")
|