Appearance
Buylist Offer Email — Design
Date: 2026-08-25 Status: Approved 2026-08-25 (Brent) — not yet implemented Source: #642 — Notion, Jonathan Medina 2026-08-20: "Still need an email with the options to accept or decline in buylist."Related: 2026-07-14-smart-buylist-customer-intake-design.md (where the claim token was introduced), 2026-07-19-buylist-portal-branding-design.md (the branding this email reuses), 2026-07-29-gdpr-deletion-design.md (the erasure path this extends)
Problem
"Send Offer to Customer" sends nothing. POST /api/buylist/orders/:id/send-offer (server/routes/buylist.js:571) flips status to offer_sent, saves, and returns. There is no notification of any kind.
The customer-facing half already works — POST /api/public/buylist/:shop/orders/:id/accept and /decline are live, gated on a claim token, and driven by client/src/pages/portal/BuylistPortalStatusPage.jsx.
What is missing is any way for the customer to reach that page. The claim token is returned exactly once, in the create-order response body (server/services/publicBuylistService.js:152-172); only its SHA-256 hash is persisted. Close the tab and the offer is unreachable forever, and the merchant has no link to hand over. The whole offer_sent → customer-accepts flow is unreachable in practice.
Scope
When the merchant clicks "Send Offer to Customer", email the customer an offer summary and a working claim link, and give the merchant that link too.
Non-goals:
- Bounce/complaint webhooks. Deferred to a follow-up (see Risks). v1 records only the provider's synchronous accept/reject.
- Per-merchant sending domains. Everything sends from one LGS Forge domain with the merchant as
Reply-To. - A source gate. Whatever reaches
send-offergets an email;send-offeris already only meaningful for customer-submitted orders and that is unchanged. - SMS, or any second channel.
Decisions (2026-08-25, Brent)
1. Which provider? — Resend
There is no Shopify-native path. The Admin API has no endpoint for sending an arbitrary transactional email; the ecosystem of "send transactional email from Flow" apps exists precisely to fill that gap. The only Admin-API email that reaches a customer is the order / draft-order invoice, which for a buylist would mean minting fake draft orders (money flows to the customer — wrong semantics), plus a write_draft_orders scope we do not hold and a merchant re-consent prompt. Ruled out.
Among third-party providers, at our volume (one email per submitted order, tens per store per month) every candidate is $0, so cost is not the discriminator. Resend wins on: 3,000/mo free with no credit card, 30-day log retention on the free tier (the support ticket we will actually get is "the customer says they never received it", and it arrives two days later), delivery webhooks for when we want them, and a $20/mo → 50k escape hatch.
Runners-up and why not: Mailgun's free tier restricts sending to 5 authorized recipients until a card is on file, and gives 1-day log retention; Brevo (300/day) and Mailjet (6k/mo) buy free headroom but weaker DX; Postmark has the best deliverability but cut its free tier 3,000 → 500 in Oct 2025; SES and ZeptoMail are cheapest at scale but pay for it in setup.
These free tiers churn — Postmark and Mailtrap both cut 3,000 → 500 in one month and SendGrid deleted its free plan outright in May 2025. That is why the send goes behind an adapter (§Components): swapping vendors must cost one file and one secret, so the vendor choice is not load-bearing.
2. Free-tier caps are platform-wide, not per store
Resend's 100/day is shared across every tenant. It binds on tenant count long before any single merchant's volume matters. At current scale it is not close; it is the number to watch as stores are added, not a per-store limit.
3. Inline send, not a queued job
The send is awaited inside the request with a bounded timeout. The merchant is standing at the counter and needs to know immediately if it failed; queuing would also put RESEND_API_KEY on the GCE worker, adding two more places to keep in sync (§5.7); and it is one email per click. There is no batching case here.
4. Re-issue the claim token at send-offer
Minting a fresh token at send time invalidates the one from order creation. That is accepted: today that link is the only one in existence and the customer has almost certainly lost it, which is the bug.
5. Token expiry is 30 days; resend-offer is in v1
Both were my call and approved. An expired-but-valid token gets a distinct 410 so the customer is told to ask the merchant for a new link rather than seeing "not found".
Design as built
Data flow
Merchant clicks "Send Offer to Customer"
→ POST /api/buylist/orders/:id/send-offer
→ mint claim token, store hash + claimTokenExpiresAt (+30d)
→ status = 'offer_sent', save
→ buylistOfferEmail.build(order, store.branding) → {subject, html, text}
→ emailService.send(...) [awaited, ~5s timeout, never throws]
→ record offerEmail.{sentAt,to,providerMessageId,status,error,attempts}, save
→ 200 { order, email: { status, error? } }
Customer clicks Accept/Decline in the email
→ https://app.lgsforge.com/portal/:shop/buylist/orders/:id?token=…
→ BuylistPortalStatusPage → existing POST /api/public/buylist/:shop/orders/:id/accept|declineThe link host is settled: server/routes/auth.js:30 builds ${APP_DEPLOYMENT_URL}/auth/callback, which must match the registered redirect URLs in shopify.app.toml:34 — so APP_DEPLOYMENT_URL is https://app.lgsforge.com, the Firebase host that serves /portal/* and rewrites /api/** to Cloud Run. One origin covers both the page and the API it calls.
Components
server/services/emailService.js— the only thing that talks to a provider.send({ to, replyTo, subject, html, text })over Resend's HTTP API using axios (already a dependency; no new npm package). Env-gated exactly likenotificationService.js: noRESEND_API_KEY→ log and return{ status: 'skipped' }, never throw._setDeps/_resetDepsseam —vi.mockof server source is silently inert under our vitest externalization (§3).server/services/buylistOfferEmail.js— pure builder, order + branding →{ subject, html, text }. No I/O, so it tests cheaply and can be rendered to a file for visual review.server/routes/buylist.js—send-offergains the mint-and-send; newPOST /api/buylist/orders/:id/resend-offer(valid only onoffer_sent).client/src/pages/buylist/BuylistReviewPage.jsx— claim link with a copy button, email status, resend action.
Changes
| File | Change |
|---|---|
server/models/BuylistOrder.js | claimTokenExpiresAt: Date; offerEmail sub-doc declared field-by-field (§5.2) |
server/services/publicBuylistService.js | findOrderByClaim rejects an expired token with a distinct status |
server/services/gdprService.js | $unset gains offerEmail.to and claimTokenExpiresAt alongside claimTokenHash (currently line 163) |
server/schemas/env.js | RESEND_API_KEY, BUYLIST_EMAIL_FROM — optional, validated when present |
.env.example | both keys, empty |
.github/workflows/deploy-api.yml | both added to the --set-env-vars block, plus matching GH Actions secrets (§5.7) |
offerEmail.status is one of sent / failed / skipped. skipped means no API key was configured — it is not an error, and it is how local dev behaves.
Status is not conditional on the email
status flips to offer_sent even when the send fails. The order is offered; the notification is best-effort, and the merchant still has the copyable link. The response carries email.status so the UI can say "Offer sent, but the email didn't go through — copy the link instead."
Existing orders
Orders with no claimTokenExpiresAt are treated as non-expiring. No migration.
Environment and DNS
Manual, one-time, not code:
- Verify a subdomain (
send.lgsforge.com) in Resend with SPF/DKIM/DMARC, so buylist sending reputation can never affect root-domain mail. BUYLIST_EMAIL_FROM=buylist@send.lgsforge.com. Display name is"<Store.branding.name> via LGS Forge";Reply-Tois the merchant.RESEND_API_KEYinto Cloud Run env and the GH Actions secret in the same sitting (§5.7). The worker does not need it.
dotenv-safe runs with allowEmptyValues: true (server/index.js:9), so adding these to .env.example means every developer must add the keys to their local .env — empty is fine, absent breaks boot.
Testing
- Builder: link carries the freshly-minted token; totals and payout method match the order; accept/decline URLs point at the portal; a still-
pendingorder's prices never appear. - emailService: correct payload with a DI'd http client; missing key →
skippedand no call; timeout →failed, no throw. - Route: re-mint changes
claimTokenHash; email failure still flips status and returns 200 withemail.status: 'failed'; non-pending→ 409;resend-offeron apendingorder → 409. - Model: round-trip write→read for
offerEmailandclaimTokenExpiresAtthat fails if a schema line is deleted (§5.2). - Expiry: valid-but-expired token → 410; valid unexpired → 200; wrong token → 404 (unchanged).
- GDPR: redaction clears
offerEmail.to. - Client: review page renders copy-link and email status; resend action posts.
§5.1 game parity is N/A — nothing here is per-game. To be stated in the PR.
Risks
- Deliverability. A spam-foldered offer is a lost trade-in and we will not know. Mitigated short-term by the copyable link (the merchant can always fall back to their own channel), properly by the deferred bounce webhook.
- Bounce webhook deferred.
offerEmail.status: 'sent'means Resend accepted it, not that it arrived. Recorded here as knowingly unwired rather than half-built (§5.9). Follow-up:email.bounced/email.deliveredvia a public endpoint with Svix signature verification. - A new outbound channel and a new secret. First non-Slack outbound path in the app; the §5.7 double-write rule applies to
RESEND_API_KEYfrom day one. - Token invalidation on resend. Each resend breaks the previous link. Fine for the intended use, would be confusing if a merchant resends while the customer is mid-decision on an older link.
