Plan: send client documents to a lender
Status: draft for review — not approved, not started. Author: FM Bot, 2026-08-11. Requested by Brock.Goal
Let an operator select documents on a contact’s Docs & Notes → Documents card and email them to an outside party — a lender or processor — at an address the operator types. This is the outbound counterpart to the upload-notification work merged on 2026-07-31 (PRs #1376–#1379). Those tell the team when a client uploads. This sends what the client uploaded onward. The two share only the CRM message helper.Non-goals
- Sending to the client. Different recipient, different copy, different consent posture.
- Bulk send across contacts.
- A lender directory or CRM objects for lenders. Operators type an address; saved shortcuts can come later once we see which lenders actually recur.
- Replacing the processing-submission attachment flow (
convex/processingAttachments.ts), which is internal and stays as-is.
The decision that blocks everything: transport
Every email path in this repo anchors on acontactId. sendGHLMessage
(lib/ghl-sdk.ts) and /api/ghl/send-email both post to the CRM’s
/conversations/messages endpoint, and SendGHLMessageParams (lib/ghl.ts:685)
has emailTo, emailCc, emailBcc — but no replyTo. There is no
non-CRM mail transport in the repo; nothing in package.json for Resend, SendGrid,
Postmark, Mailgun, or SES.
So sending a lender an email through the existing path means:
- The message is filed in the client’s conversation thread.
- The lender’s reply routes back into the client’s thread, where it can be mistaken for the client, and where client-facing automations may fire on it.
- The client’s CRM record accumulates lender correspondence.
Option A — send through the CRM (emailTo = lender)
- For: no new infrastructure, no DNS work, per-location OAuth already in place,
every send is automatically visible in the CRM. Precedent exists —
app/api/ghl/notify-offers-readyalready sends to arbitrary non-contact addresses daily, despite the CRM docs sayingemailTo“should be a valid email address associated with the contact.” - Against: the three problems above, none of which we can mitigate — without
replyTothere is no way to steer the reply. Also keeps us dependent on undocumentedemailTobehavior for a flow that now carries client financial documents. - Also requires: a URL the CRM can fetch for longer than a Convex storage
URL lives (see “Attachment mechanics”).
ctx.storage.getUrlis a signed ~15-minute link, not a permanent public file. Handing it to the CRM would expire before a lender opens a degraded-to-link attachment, so Option A still needs a longer-lived share route.
Option B — a transactional email provider for outbound lender mail
- For: correct reply-to, so lender replies reach the operator and never touch the client’s thread. Attachments are sent as real attachments (base64), which means we never mint a public URL for a bank statement at all — the largest security concern in Option A disappears rather than being mitigated. Better deliverability control for a compliance-sensitive send.
- Against: a new dependency, a sending domain with SPF/DKIM, a new secret, and ongoing cost. Loses automatic CRM visibility, so we would mirror each send as a CRM note on the contact — which we want in the audit trail regardless.
- Cost of delay: this is the kind of thing that is much cheaper to choose now than to migrate to after operators have been sending through the client’s thread for three months.
Recommendation
Option B. The deciding factor is not reply routing. Option A cannot usectx.storage.getUrl as the CRM attachment: those URLs expire in ~15 minutes.
Giving the CRM something that lasts days means minting a longer-lived fetchable
URL for bank statements and tax returns. Option B never mints that URL. Everything
else is a preference; that one is a security property.
Option A is a legitimate choice if the priority is shipping without DNS work. If we
take it, take it deliberately and with the signed-share route below, not a
Convex storage URL.
This decision is Brock’s — it adds a vendor and a sending domain. Nothing below
gets built until it is made, because the transport changes roughly half the work.
Attachment mechanics
Documents live in Convex storage.documents.getDocumentUrl returns
ctx.storage.getUrl(storageId). That is a signed URL that expires in
approximately 15 minutes — the same reason analytics and prospecting report
emails proxy through a stable download route instead of embedding the storage
URL (app/api/analytics/reports/[storageId]/download/route.ts,
convex/prospecting/reportsEmail.ts). Anyone holding the string can fetch the
file for that window, without a session, and the fetch is not logged. The URL
is not permanent, not unrevocable-forever, and not safe to treat as a CRM
attachment link.
Under Option B this never comes up: read the blob server-side and attach it.
Under Option A we must give the CRM a URL it can fetch for days, and it must
not be the Convex storage URL (that link is dead after ~15 minutes). The repo
already has the right pattern in convex/prospecting/leadToken.ts: an
HMAC-signed, expiring token keyed on SHORT_LINK_HMAC_SECRET. Mirror it as a
document-share route that streams the blob so the lender never receives
the short-lived storage URL:
- Payload
documentId|sendId|expiresAt, signed with the same secret. - TTL long enough for the CRM to fetch and for a lender to open a link if the provider degrades a large attachment to one — 1–7 days, to be decided, not minutes.
- Route checks, in order:
- HMAC valid.
- Token not expired.
- Send row exists and has not been revoked.
- Ownership binding. Load the document and the send. Require
document.locationId === send.locationId,document.contactId === send.contactId, anddocumentIdis one ofsend.documentIds. Signature, expiry, and revocation alone do not establish that the file belongs to that send. This is the same class of check as the lead-token relay (app/api/lb/r/[slug]/route.ts): afterverifyLeadTokenit still requiresverified.partnerId === shortLink.partnerIdandlead.partnerId === shortLink.partnerIdbefore redirecting. - Log the fetch, then stream the blob.
- Revocable: killing the send row kills every link in it. A valid signature for a revoked or cross-bound pair still 404s.
Provider limits (verified 2026-08-11)
- The CRM’s
attachmentsarray takes a maximum of 5 URLs. - The CRM composer caps email attachments at 20 MB; above that it re-hosts to the Media Library and sends a link instead. Receiving providers cap independently (Gmail/Yahoo 25 MB, Outlook.com 10 MB).
- The CRM’s own file-upload endpoint caps at 5 MB per file, which is why we attach by URL rather than uploading.
Security and audit
Forwarding client financial documents to a free-typed address is a data-egress surface, and worth naming plainly: an operator, or anyone who has taken over an operator session, can exfiltrate a client’s bank statements to any address they like. We cannot design that away, so make it observable and bounded.- Audit table
documentSends: who sent it, which documents, recipient address(es), subject, timestamp, delivery outcome, and — for Option A — every signed-URL fetch with timestamp. Model it on thedeliveriesarray shipped in #1378. - Authorization follows the iframe pattern already documented in
AGENTS.md:requireGHLInstallationon the location, and reuse whatever operator/processing-team gate the Documents card already applies.ctx.auth.getUserIdentity()is unavailable here, so caller identity comes through the session actor pattern. - Rate limit per location per day; a burst is the signature of exfiltration.
- No silent sends. Every send also writes a CRM note on the contact recording that documents went to an outside party — the client’s record should show it. The note is part of the attempt below: write it once, never on a duplicate dispatch.
- Consider a per-location allowlist of lender domains, defaulting to off. Worth discussing; it may be more friction than it buys.
Idempotent attempt lifecycle
Recording the audit row, calling the transport, writing the CRM note, and recording the outcome are separate steps. Implemented as a fire-and-forget sequence, a retry after the row exists can send a second email and a second CRM note, and a crash after insert leaves the row permanently non-terminal.- Stable attempt key. Minted when the operator confirms send, unique per
intended dispatch:
locationId+contactId+ a client-generatedattemptId(UUID from the dialog submit). Unique indexby_attemptKeyondocumentSends. A second submit with the same key returns the existing row; it does not open a new send and does not call transport again. - Statuses.
pending(row written, nothing sent) →dispatching(worker holds a lease viadispatchLeaseExpiresAt) →sent|failed. Terminal states are never overwritten by a later dispatch of the same key. - Duplicate-dispatch. The worker claims
pending→dispatchingonly when no live lease exists. A concurrent or retried job that findsdispatchingwith a live lease, or alreadysent/failed, returns without calling the transport and without writing another CRM note. - Abandoned-after-audit-row recovery. If the worker dies after insert, or
after claiming the lease, a follow-up claim is allowed only when the lease
has expired and the status is still
pendingordispatching. Recovery either finishes that same attempt or marks itfailedwith reasonabandoned. It does not mint a second email or CRM note. An operator who wants to try again starts a new attempt with a new key. - CRM note is part of the attempt. Write it once, after the transport
accepts the message, keyed by the same attempt. A retry of a
sentattempt is a no-op for the note as well. TheattemptChannelshape from #1378 still applies to converting a thrown transport error into a recordedfailedoutcome.
Phases
Each phase is its own branch and PR, per the repo convention. Phase 1 — selection and the send surface. Add multi-select toDocumentList, a
“Send to lender” action on the Documents card, and a dialog: recipient, optional cc,
subject, message, selected documents, size and count guards. No sending yet; the
action resolves to a no-op mutation that records intent. Ships the UI for review
without any egress.
Phase 2 — the audit table and the send record. documentSends schema
(including attemptKey, status, and dispatch lease), the mutation that opens
a send row, and the CRM-note field reserved on that row. Still no outbound
mail. Schema change, so Out of Scope under VISION.md and needs the approval
this plan is requesting.
Phase 3 — transport. Option B: the provider integration, sending domain, and
base64 attachment assembly. Option A: the signed-share route (with ownership
binding), the fetch log, and the sendGHLMessage call. Dispatch claims the
lease, calls transport once, writes the CRM note once, then records sent or
failed. This is the phase that differs most by decision.
Phase 4 — delivery outcomes. Surface per-send status back on the Documents card,
including bounces where the transport reports them, and the fetch log if Option A.
Test plan
- Selection caps at 5; total-size warning fires at the threshold; the send button is disabled with an explanation rather than failing at submit.
- Recipient validation rejects malformed addresses and, if we adopt one, addresses outside the allowlist.
- A send records the operator, documents, and recipient before the transport is called, so a transport failure still leaves an audit row.
- Transport failure records a failed outcome and does not claim delivery — the
attemptChannelshape from #1378 applies directly. - The same
attemptKeysubmitted twice returns the existing row and does not send a second email or write a second CRM note. - An attempt left
pending/dispatchingafter its lease expires is recovered to a terminalfailed/abandonedstate, or completed as the original attempt — never as a second dispatch. - Option A only: a signed link expires; a revoked send’s link stops working;
each fetch is logged once; a token whose document and send disagree on
locationId,contactId, or membership indocumentIdsis rejected. - Copy says “CRM”, never GHL or HighLevel, per the locked decision in
VISION.md.
Open questions for Brock
- Option A or B. Blocks everything else.
- If B, which provider and which sending domain?
- Signed-link TTL, if A. Days, not minutes — but how many?
- Lender domain allowlist: worth the friction, or rely on the audit log?
- Should the client’s CRM record show the documents that were sent, or only that a send happened? I have assumed the file names are recorded.

