Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Unified Viewer presentation with a Start/Stop play lifecycle. Playing is present

**Functions:** getScene, createStripeSession, stripeWebhook, geoid, generateReplicateImage, generateFalImage, onAssetWritten, getUploadQuota, onSplatAssetCreated

**Lifecycle emails:** one send path (`sendLifecycleEmail` in `public/functions/email/`) with per-stream Postmark routing, `emailPrefs` unsubscribe suppression, and transactional stop-rules on `emailLog`. Triggers: Auth onCreate (welcome), `stripeWebhook` (post-upgrade; failed-payment handler dormant — Stripe hosted dunning instead), hourly sweep (abandoned checkout, pricing nudge, geo-not-used), daily sweep (token exhaustion). Docs: `docs/email-lifecycle.md`.
**Lifecycle emails:** one send path (`sendLifecycleEmail` in `public/functions/email/`) with per-stream Postmark routing, `emailPrefs` unsubscribe suppression, and transactional stop-rules on `emailLog`. Triggers: Auth onCreate (welcome), `stripeWebhook` (post-upgrade; failed-payment handler dormant — Stripe hosted dunning instead), hourly sweep (abandoned checkout, pricing nudge, geo-not-used), daily sweep (token exhaustion). Localized (en/es/pt-BR/fr, hand-written copy per locale in `templates.js`): recipient locale resolved from `socialProfile/{uid}` (`locale` explicit pick > `detectedLocale` captured at sign-in > en) via `email/locale.js`. Docs: `docs/email-lifecycle.md`.

## User Asset Upload

Expand Down
66 changes: 58 additions & 8 deletions docs/email-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ re-engagement) from Cloud Functions via Postmark, with per-category
unsubscribe, stop-rules, and a full send audit — no external ESP workflows.

**Code:** `public/functions/email/` — `lifecycle-email.js` (send service),
`stop-rules.js`, `postmark.js` (transport), `templates.js` (all copy),
`stop-rules.js`, `postmark.js` (transport), `templates.js` (all copy, per
locale), `locale.js` (recipient locale resolution),
`lifecycle-triggers.js` (welcome on signup), `lifecycle-sweeps.js` (hourly
sweep) · Stripe-driven sends live in `index.js`'s `stripeWebhook` ·
`public/functions/scheduled/scheduledEmails.js` (daily sweep, see
Expand Down Expand Up @@ -72,8 +73,8 @@ Postmark message streams double as the unsubscribe preference center:
per-email `lastSentAt` / `sentCount` / `dedupeKeys`, per-category
`lastSentAt`.
- **`emailLog/{uid}/sends/{autoId}`** — audit of every attempted send:
`{ emailId, category, stream, to, subject, dedupeKey, status, messageId,
createdAt, sentAt }`. A record stuck on `pending` means an instance died
`{ emailId, category, stream, to, subject, locale, dedupeKey, status,
messageId, createdAt, sentAt }`. A record stuck on `pending` means an instance died
between claiming and the Postmark response — rare enough at current volumes
that it's surfaced as data rather than swept.
- **`emailPrefs/{uid}`** — per-stream opt-out state mirrored from the Postmark
Expand All @@ -94,11 +95,51 @@ Declared per email, enforced transactionally (`email/stop-rules.js`):
| `stopIfPro: true` | skip PRO/MAX users (upsell emails) |
| `dedupeKey` (send param) | once per external key — invoice id, checkout session id |

## Localization

Lifecycle emails send in the recipient's language — the same locale set the
editor UI ships (`en` / `es` / `pt-BR` / `fr`, see
`src/shared/i18n/locales.js`). Two-part design (#1841):

- **Capture (client):** the shared Auth context
(`src/shared/contexts/Auth.context.jsx`) writes
`socialProfile/{uid}.detectedLocale` (from `navigator.language`) on
sign-in — fire-and-forget, localStorage-guarded to one write per
uid+locale. An explicit View > Language pick writes
`socialProfile/{uid}.locale` (store.js `setLocale` /
`useProfileLocaleSync`) and always wins.
- **Resolve + render (server):** `sendLifecycleEmail` resolves the locale via
`email/locale.js` (`locale` > `detectedLocale` > `en`, region-insensitive
matching mirroring `matchSupportedLocale`) and passes it as the third
argument to `getSubject/getHtmlBody/getTextBody`. Templates in
`templates.js` hold per-locale copy (hand-written, reviewed in-repo);
a whole missing locale entry falls back to the full English entry, and a
locale entry that omits a single key inherits just that key from English —
so an incomplete translation degrades one field to English rather than
interpolating a literal `undefined`. `defineTemplate` also normalizes the
locale before rendering, so an unknown tag renders English *and* is labeled
`lang="en"`. The broadcast unsubscribe footer and the "Hi there" greeting
fallback localize too.
- **Welcome race:** Auth `onCreate` can fire before the client's
`detectedLocale` write lands, so the welcome trigger (alone) briefly polls
for the signal (`waitForEmailLocale`, ~9s max) before sending. The poll is
passed to `sendLifecycleEmail` as `resolveLocale`, which runs it only after
confirming the account has an email — an emailless account (e.g. anonymous
auth) resolves to `no-email` immediately instead of waiting out the poll.
Every other email fires hours-to-days after signup and just reads the
profile.
- Callers may pass an explicit `locale` to `sendLifecycleEmail` to bypass
resolution; the audit record stores the locale each send actually used.

## Adding a new lifecycle email

1. **Template** — `{ getSubject(name, data), getHtmlBody(name, data),
getTextBody(name, data) }`. Don't add an unsubscribe footer yourself; the
service appends it for broadcast streams.
1. **Template** — build it in `templates.js` with `defineTemplate`, providing
copy for **every** supported locale (subject, bodies, CTA label,
footnote); the resulting object implements `{ getSubject(name, data,
locale), getHtmlBody(name, data, locale), getTextBody(name, data,
locale) }`. Don't add an unsubscribe footer yourself; the service appends
a localized one for broadcast streams. `test/core/email-templates.test.js`
sanity-checks every template × locale automatically.
2. **Routing** — pick a stable `emailId`, a `category`, and a `stream`
(transactional → `outbound`; marketing/behavioral → a broadcast stream).
3. **Rules** — compose from the table above.
Expand All @@ -118,10 +159,13 @@ Declared per email, enforced transactionally (`email/stop-rules.js`):

## Testing and manual dry runs

- **Unit:** `npm test` covers stop-rules (`test/core/email-stop-rules.test.js`).
- **Unit:** `npm test` covers stop-rules (`test/core/email-stop-rules.test.js`)
and every template × locale (`test/core/email-templates.test.js` — chrome,
CTA links, greeting fallbacks, en-fallback behavior).
- **Emulator:** `npm run test:rules` (JDK 21+, local-only) covers the send
service end-to-end — claims, rollback, suppression, unsubscribe footer,
concurrency — and the migrated tokenExhaustion sweep
concurrency, locale resolution (explicit > detected > en, the welcome-race
poll) — and the migrated tokenExhaustion sweep
(`test/rules/lifecycle-email.emulator.test.js`).
- **Pipeline test (admin claim required), from the browser console:**
```js
Expand Down Expand Up @@ -178,6 +222,12 @@ deploying email changes to dev, before promoting to prod.
8. **Welcome on signup.** Create a fresh account on dev → welcome email
arrives within a minute (no unsubscribe footer, `outbound` stream);
`emailLog/{new-uid}.emails.welcome` appears.
**Localized welcome:** repeat with the browser language set to Spanish
(or with `socialProfile/{uid}.locale` set to `es` before signup can't
exist — use a browser profile with `es` as the preferred language) → the
welcome arrives in Spanish and the `sends` audit record shows
`locale: 'es'`. Confirm `socialProfile/{uid}.detectedLocale` was written
at first sign-in.
9. **Purchase flow.** Buy a plan on dev with a Stripe test card → post-upgrade
welcome arrives once; `checkoutSessions/{sessionId}` flips to
`status: 'complete'` in Firestore. Or skip the UI and run
Expand Down
98 changes: 82 additions & 16 deletions public/functions/email/lifecycle-email.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,24 +35,66 @@ const { assertAppCheck } = require('../app-check.js');
const { isUserProInternal } = require('../token-management.js');
const { sendPostmarkEmail, getUserInfo } = require('./postmark.js');
const { evaluateStopRules } = require('./stop-rules.js');
const {
DEFAULT_EMAIL_LOCALE,
normalizeEmailLocale,
resolveEmailLocale
} = require('./locale.js');

const TRANSACTIONAL_STREAM = 'outbound';
// Broadcast streams live in Postmark (created manually in the dashboard;
// stream IDs must match these strings). Add 'expansion' / 're-engagement'
// when P1 emails need them.
const BROADCAST_STREAMS = ['conversion', 'lifecycle'];

// Appended to broadcast-stream messages. Postmark replaces the placeholder
// with a per-recipient, per-stream unsubscribe URL.
const UNSUBSCRIBE_HTML = `
// Appended to broadcast-stream messages, in the recipient's locale. Postmark
// replaces the placeholder with a per-recipient, per-stream unsubscribe URL.
const unsubscribeHtml = (prompt, label) => `
<p style="font-size: 12px; color: #999;">
Don't want these emails?
<a href="{{{ pm:unsubscribe_url }}}" style="color: #6366f1;">Unsubscribe</a>.
${prompt}
<a href="{{{ pm:unsubscribe_url }}}" style="color: #6366f1;">${label}</a>.
</p>`;
const UNSUBSCRIBE_TEXT = `
const unsubscribeText = (prompt, label) => `

---
Don't want these emails? Unsubscribe: {{{ pm:unsubscribe_url }}}`;
${prompt} ${label}: {{{ pm:unsubscribe_url }}}`;

const UNSUBSCRIBE = {
en: {
html: unsubscribeHtml("Don't want these emails?", 'Unsubscribe'),
text: unsubscribeText("Don't want these emails?", 'Unsubscribe')
},
es: {
html: unsubscribeHtml(
'¿No quieres recibir estos correos?',
'Cancela tu suscripción'
),
text: unsubscribeText(
'¿No quieres recibir estos correos?',
'Cancela tu suscripción'
)
},
'pt-BR': {
html: unsubscribeHtml(
'Não quer receber estes e-mails?',
'Cancelar inscrição'
),
text: unsubscribeText(
'Não quer receber estes e-mails?',
'Cancelar inscrição'
)
},
fr: {
html: unsubscribeHtml(
'Vous ne souhaitez plus recevoir ces e-mails ?',
'Se désinscrire'
),
text: unsubscribeText(
'Vous ne souhaitez plus recevoir ces e-mails ?',
'Se désinscrire'
)
}
};

/**
* Send one lifecycle email to one user, enforcing suppression + stop-rules.
Expand All @@ -68,10 +110,17 @@ Don't want these emails? Unsubscribe: {{{ pm:unsubscribe_url }}}`;
* @param {string} p.emailId - stable id, e.g. 'welcome', 'checkoutAbandoned1h'
* @param {string} p.category - preference category, e.g. 'transactional', 'conversion'
* @param {string} p.stream - Postmark stream ('outbound' | broadcast stream id)
* @param {Object} p.template - { getSubject(name, data), getHtmlBody(name, data), getTextBody(name, data) }
* @param {Object} p.template - { getSubject(name, data, locale), getHtmlBody(name, data, locale), getTextBody(name, data, locale) }
* @param {Object} [p.data] - template data
* @param {Object} [p.rules] - stop-rules (see stop-rules.js)
* @param {string} [p.dedupeKey] - once-per-key guard (invoice id, session id)
* @param {string} [p.locale] - explicit send locale; omit to resolve from the
* recipient's profile (locale.js), falling back to 'en'
* @param {(db, uid) => Promise<string>} [p.resolveLocale] - custom locale
* resolver, used instead of the default profile lookup when no explicit
* `locale` is given. Runs only after the account is confirmed to have an
* email, so a caller can attach an expensive resolver (e.g. the welcome
* trigger's ~9s signup-race poll) without spending it on emailless accounts.
* @param {boolean} [p.dryRun] - evaluate everything, claim and send nothing
* @returns {Promise<{action: 'sent'|'would-send'|'skipped'|'no-email'|'error', reason?: string, messageId?: string, to?: string, subject?: string}>}
*/
Expand All @@ -85,6 +134,8 @@ const sendLifecycleEmail = async ({
data = {},
rules = {},
dedupeKey = null,
locale = null,
resolveLocale = null,
dryRun = false
}) => {
const isBroadcast = stream !== TRANSACTIONAL_STREAM;
Expand Down Expand Up @@ -113,14 +164,28 @@ const sendLifecycleEmail = async ({

const summaryRef = db.collection('emailLog').doc(uid);

const subject = template.getSubject(userInfo.displayName, data);
let htmlBody = template.getHtmlBody(userInfo.displayName, data);
let textBody = template.getTextBody(userInfo.displayName, data);
// Locale: explicit param wins (normalized); else a caller-supplied resolver
// (e.g. the welcome trigger's signup-race poll); else the recipient's
// socialProfile (explicit UI pick > detected browser locale > 'en'). This
// sits after the no-email early return above, so a slow resolver is never
// spent on an account that can't be emailed. Templates receive it as their
// third argument.
const sendLocale = locale
? normalizeEmailLocale(locale)
: resolveLocale
? await resolveLocale(db, uid)
: await resolveEmailLocale(db, uid);

const subject = template.getSubject(userInfo.displayName, data, sendLocale);
let htmlBody = template.getHtmlBody(userInfo.displayName, data, sendLocale);
let textBody = template.getTextBody(userInfo.displayName, data, sendLocale);
if (isBroadcast) {
const unsubscribe =
UNSUBSCRIBE[sendLocale] || UNSUBSCRIBE[DEFAULT_EMAIL_LOCALE];
htmlBody = htmlBody.includes('</body>')
? htmlBody.replace('</body>', `${UNSUBSCRIBE_HTML}</body>`)
: htmlBody + UNSUBSCRIBE_HTML;
textBody += UNSUBSCRIBE_TEXT;
? htmlBody.replace('</body>', `${unsubscribe.html}</body>`)
: htmlBody + unsubscribe.html;
textBody += unsubscribe.text;
}

// Transaction: evaluate stop-rules against the live summary doc and claim
Expand Down Expand Up @@ -184,6 +249,7 @@ const sendLifecycleEmail = async ({
stream,
to: userInfo.email,
subject,
locale: sendLocale,
dedupeKey,
status: 'pending',
createdAt: now
Expand Down Expand Up @@ -249,7 +315,7 @@ const sendLifecycleEmail = async ({
const TEST_TEMPLATE = {
getSubject: (userName, { stream }) =>
`[3DStreet test] lifecycle email pipeline (${stream} stream)`,
getTextBody: (userName, { stream }) => `Hi ${userName},
getTextBody: (userName, { stream }) => `Hi ${userName || 'there'},

This is a test of the 3DStreet lifecycle email pipeline, sent via the "${stream}" Postmark stream.

Expand All @@ -264,7 +330,7 @@ https://3dstreet.com`,
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #1a1a1a;">Hi ${userName},</h2>
<h2 style="color: #1a1a1a;">Hi ${userName || 'there'},</h2>
<p>This is a test of the 3DStreet lifecycle email pipeline, sent via the <strong>${stream}</strong> Postmark stream.</p>
<p>If you weren't expecting this, an admin is testing email infrastructure — no action needed.</p>
<p style="color: #666;">The 3DStreet Team<br>
Expand Down
21 changes: 19 additions & 2 deletions public/functions/email/lifecycle-triggers.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,41 @@
const functions = require('firebase-functions/v1');
const admin = require('firebase-admin');
const { sendLifecycleEmail } = require('./lifecycle-email.js');
const { waitForEmailLocale } = require('./locale.js');
const TEMPLATES = require('./templates.js');

/**
* Send the welcome email to one user. Split from the trigger so the emulator
* suite can exercise it directly. Accounts without an email address (e.g.
* anonymous auth) resolve to action 'no-email' inside the send service.
*
* Locale: onCreate fires the instant the account exists, which can beat the
* client's detectedLocale write to socialProfile by a few seconds — and a
* wrong-language welcome is the most visible miss localization can make. So
* this trigger (alone) briefly polls for the locale signal before sending;
* `localeWait` exists so tests can shrink the poll to a single read. The poll
* is handed to sendLifecycleEmail as `resolveLocale` rather than run here, so
* it fires only after the account is confirmed to have an email — an
* emailless account (e.g. anonymous auth) resolves to 'no-email' immediately
* instead of waiting out the full ~9s poll for a signal it will never use.
*/
const sendWelcomeEmailForUser = (db, uid, { dryRun = false } = {}) =>
sendLifecycleEmail({
const sendWelcomeEmailForUser = async (
db,
uid,
{ dryRun = false, localeWait = undefined } = {}
) => {
return sendLifecycleEmail({
db,
uid,
emailId: 'welcome',
category: 'transactional',
stream: 'outbound',
template: TEMPLATES.welcome,
rules: { onceEver: true },
resolveLocale: (d, u) => waitForEmailLocale(d, u, localeWait),
dryRun
});
};

const sendWelcomeEmail = functions
.runWith({ secrets: ['POSTMARK_API_KEY'] })
Expand Down
Loading