Contacting a representative
Some offices publish an email address; most congressional offices only publish a web contact form. The app handles both — and when the server can't submit a form on the user's behalf, it falls back to pre-filling the form inside an in-app browser.
Two branches from one button
The Email action on a representative card inspects the contact field. A real email address opens a mailto: draft — written by hand or generated by the AI assist, which sends the topic to POST /ai-email (a server-proxied DeepSeek chat completion, non-streaming) and returns a subject and body. No published address means the office uses a web form, and the app switches to server-side delivery: a sign-in-gated bottom sheet collects subject, message (with the same AI assist inline), and the constituent details offices require to confirm residency — name, email for the reply, and postal address. Profile fields are prefilled; the street address deliberately is not, and is never persisted anywhere, client or server. This flow is the one place the app sends the representative's full external identifier rather than the 7-character short form used elsewhere.
Delivery and polling
POST /representatives/contact enqueues the delivery and returns a message record. The sheet then polls GET /representatives/contact/{message_id} every 3 seconds for up to 2 minutes — a window chosen to match the backend's own per-delivery timeout with headroom for queueing. Status values are a closed set: queued and delivering are in-flight; delivered, failed, captcha_required, and unsupported are terminal. The client refuses to treat an unknown status as finished — a forward-compatibility decision, so a new server status can never be misread as success.
The outcome screen distinguishes three shapes. Delivered shows confirmation and where the reply will go. A poll that times out while the backend still reports in-flight shows "Still delivering" and pointedly does not offer the fallback form — the message may yet arrive, and a pre-filled form here would invite a duplicate delivery. Only the three terminal failures surface the "Open Pre-Filled Form" button: captcha_required (the office's form has a CAPTCHA the server cannot solve), unsupported (a form layout the server's executor doesn't handle yet), and failed.
The WebView fallback
The fallback URL resolves in preference order: the server's fallback_url, then the rep's known form URL, then their website. Before anything renders, two gates apply. The URL must be HTTP(S) at all, and it must be HTTPS specifically to get the in-app treatment — a cleartext form could be intercepted or rewritten before the script fills in the user's address and message, so HTTP URLs open in the external browser with no prefill. Platforms without WebView support (everything but Android/iOS) also fall back to the external browser.
Inside the WebView, main-frame navigation is restricted to the same HTTPS origin (scheme, host, and effective port), with off-origin links pushed to the external browser; sub-frames are exempt so CAPTCHA widgets keep working. On every page load the app injects an autofill script carrying the user's composed message and details.
The autofill script's rules
The script mirrors the backend's heuristic form matcher, but runs in the rendered page — which is exactly why it works on JavaScript-built forms the server's plain-HTTP executor cannot see. Its safety rules are strict: it only fills fields that are currently empty, so it can never overwrite something the user typed; it never touches checkboxes, radios, or buttons, and never submits — the user always presses the site's own Send; and a form is only filled when at least two field slots match, so stray search boxes and newsletter signups are left alone. Field matching runs an ordered slot table (email, phone, ZIP, first/last name, state, street, city, subject, topic, message, full name) against each control's name, id, label, placeholder, and aria-label. Values are set through the native prototype setters with synthetic input/change events so React-style controlled forms accept them, and each filled field flashes a brief outline so the user can see what was touched. A MutationObserver re-runs the fill (debounced 400 ms) as multi-step form wizards render new fields — still only filling empty ones.
Failure vocabulary
| Code | Meaning | User-facing consequence |
|---|---|---|
delivered | Form submitted by the server | Confirmation; replies go to the given email |
queued / delivering | Still in the delivery queue/worker | "Still delivering" after poll timeout; no fallback offered |
captcha_required | Office form gated by CAPTCHA | Pre-filled WebView; user completes CAPTCHA and sends |
unsupported | Form layout the server can't drive yet | Pre-filled WebView |
failed | Form rejected the submission | Pre-filled WebView |
too_many_pending | Per-user pending-delivery cap hit | Asked to wait for in-flight messages |
queue_full | Server delivery queue at capacity | Asked to retry in a minute |
unknown_member | Rep not deliverable through Civiq | Explanatory error in the sheet |
Dormant surface: GET /representatives/contact (the sent-message list) is implemented in the service layer but has no UI caller yet — there is no message-history screen. It exists so one can be added without a new API surface.