> ## Documentation Index
> Fetch the complete documentation index at: https://docs.myfundingmachine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Lender document send plan

# 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 a `contactId`. `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:

1. The message is filed in **the client's** conversation thread.
2. 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.
3. The client's CRM record accumulates lender correspondence.

That is tolerable for a one-way notification. It is a poor fit for lender
correspondence, which is a conversation with a third party about the client.

### 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-ready` already sends to arbitrary non-contact addresses
  daily, despite the CRM docs saying `emailTo` "should be a valid email address
  associated with the contact."
* **Against:** the three problems above, none of which we can mitigate — without
  `replyTo` there is no way to steer the reply. Also keeps us dependent on undocumented
  `emailTo` behavior 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.getUrl` is 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 use
`ctx.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:
  1. HMAC valid.
  2. Token not expired.
  3. Send row exists and has not been revoked.
  4. **Ownership binding.** Load the document and the send. Require
     `document.locationId === send.locationId`,
     `document.contactId === send.contactId`, and `documentId` is one of
     `send.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`): after `verifyLeadToken` it still
     requires `verified.partnerId === shortLink.partnerId` and
     `lead.partnerId === shortLink.partnerId` before redirecting.
  5. 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 `attachments` array 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.

So the UI must cap selection at 5 documents and warn above \~10 MB total, since Outlook
is the likeliest lender mailbox.

## 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 the `deliveries` array shipped in #1378.
* **Authorization** follows the iframe pattern already documented in `AGENTS.md`:
  `requireGHLInstallation` on 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-generated
  `attemptId` (UUID from the dialog submit). Unique index `by_attemptKey` on
  `documentSends`. 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 via `dispatchLeaseExpiresAt`) → `sent` | `failed`. Terminal
  states are never overwritten by a later dispatch of the same key.
* **Duplicate-dispatch.** The worker claims `pending` → `dispatching` only
  when no live lease exists. A concurrent or retried job that finds
  `dispatching` with a live lease, or already `sent`/`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 `pending` or `dispatching`. Recovery
  either finishes that same attempt or marks it `failed` with reason
  `abandoned`. 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 `sent`
  attempt is a no-op for the note as well. The `attemptChannel` shape from
  \#1378 still applies to converting a thrown transport error into a recorded
  `failed` outcome.

## Phases

Each phase is its own branch and PR, per the repo convention.

**Phase 1 — selection and the send surface.** Add multi-select to `DocumentList`, 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
  `attemptChannel` shape from #1378 applies directly.
* The same `attemptKey` submitted twice returns the existing row and does not
  send a second email or write a second CRM note.
* An attempt left `pending`/`dispatching` after its lease expires is recovered
  to a terminal `failed`/`abandoned` state, 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 in `documentIds` is rejected.
* Copy says "CRM", never GHL or HighLevel, per the locked decision in `VISION.md`.

## Open questions for Brock

1. **Option A or B.** Blocks everything else.
2. If B, which provider and which sending domain?
3. Signed-link TTL, if A. Days, not minutes — but how many?
4. Lender domain allowlist: worth the friction, or rely on the audit log?
5. 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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.