Skip to content

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-offer gets an email; send-offer is 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|decline

The 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 like notificationService.js: no RESEND_API_KEY → log and return { status: 'skipped' }, never throw. _setDeps/_resetDeps seam — vi.mock of 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.jssend-offer gains the mint-and-send; new POST /api/buylist/orders/:id/resend-offer (valid only on offer_sent).
  • client/src/pages/buylist/BuylistReviewPage.jsx — claim link with a copy button, email status, resend action.

Changes

FileChange
server/models/BuylistOrder.jsclaimTokenExpiresAt: Date; offerEmail sub-doc declared field-by-field (§5.2)
server/services/publicBuylistService.jsfindOrderByClaim 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.jsRESEND_API_KEY, BUYLIST_EMAIL_FROM — optional, validated when present
.env.exampleboth keys, empty
.github/workflows/deploy-api.ymlboth 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:

  1. Verify a subdomain (send.lgsforge.com) in Resend with SPF/DKIM/DMARC, so buylist sending reputation can never affect root-domain mail.
  2. BUYLIST_EMAIL_FROM=buylist@send.lgsforge.com. Display name is "<Store.branding.name> via LGS Forge"; Reply-To is the merchant.
  3. RESEND_API_KEY into 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-pending order's prices never appear.
  • emailService: correct payload with a DI'd http client; missing key → skipped and no call; timeout → failed, no throw.
  • Route: re-mint changes claimTokenHash; email failure still flips status and returns 200 with email.status: 'failed'; non-pending → 409; resend-offer on a pending order → 409.
  • Model: round-trip write→read for offerEmail and claimTokenExpiresAt that 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.delivered via 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_KEY from 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.