Backend Aug 22-25: AI analysis, welcome-packet templating, LetterStream, DocuSeal, staff RBAC + tier gate
- 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).
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user