"""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
.... 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")