@aglyn/plugins-commerce 1.0.0-beta.203 → 1.0.0-beta.204

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-recover.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport {\n consumeMembershipRecoverAttempt,\n consumeMembershipRecoverSend,\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n isEmailSuppressed,\n meterHostEmail,\n RECOVER_MIN_MEMBER_AGE_MS,\n} from '@aglyn/tenant-data-admin'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { mintPasswordResetToken } from './membership'\nimport { readMemberPasswordHash } from './member-credentials'\nimport { readClientIp } from '@aglyn/aglyn/app-utils/request-ip'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Password-recovery request (AGL-552). ALWAYS answers `200 {ok:true}` once\n * the input validates and the caller is inside its rate budget — whether or\n * not the email belongs to a member — so the endpoint can't be used to probe\n * account existence. When a member matches, a single-use one-hour token\n * (bound to the current password hash, see membership.ts) is mailed via\n * Resend with a link to the site's `/recover` route.\n *\n * ## Abuse controls (AGL-1966)\n *\n * This is an unauthenticated endpoint that makes us send mail from\n * `noreply@aglyn.com` to an address the caller chose, and it composes with\n * `membership/register` — which admits arbitrary addresses because member\n * accounts are unlimited on every plan (AGL-889) — into a mail relay. The\n * bounces from that land on the sending reputation shared by every customer's\n * transactional mail.\n *\n * Four controls, in the order they run, and the ORDER is part of each:\n *\n * 1. **Attempt caps** — per recipient and per IP, durable\n * (`membership-recover-throttle.ts`). Consumed BEFORE the member lookup so\n * a member and a non-member spend the identical budget and receive the\n * identical answer; a visible 429 from here therefore leaks nothing. The\n * per-instance `Map` this replaced reset on every cold start and was kept\n * per instance, which on Vercel is close to no limit at all (AGL-794).\n * 2. **The young-member guard** — a member row created inside\n * `RECOVER_MIN_MEMBER_AGE_MS` is not worth a reset mail. Register-then-\n * recover is the whole attack and nobody forgets a password ten minutes\n * after choosing it. Silent-success exit.\n * 3. **The platform suppression list** — see the note on the call below.\n * Silent-success exit.\n * 4. **The per-site daily send ceiling** — consumed only when a message is\n * actually about to go out. Because that branch is reachable only for real\n * members, its refusal MUST be the silent-success exit rather than an\n * error, or the ceiling becomes the oracle everything else avoids.\n *\n * What remains unbounded is stated rather than papered over: a genuine botnet\n * still gets `RECOVER_ATTEMPTS_PER_IP` per source, and an attacker who can\n * create many sites gets the per-site ceiling once per site. Host creation is\n * itself rate-limited, and bounding the botnet case needs an edge WAF, not an\n * application limiter — the same conclusion `plugin-api-rate-limit.ts` reaches.\n */\nexport const membershipRecoverHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.body?.hostId ?? '')\n const email = String(req.body?.email ?? '')\n .trim()\n .toLowerCase()\n if (!hostId || !EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Invalid request' })\n }\n // Null when nothing readable names the caller; the throttle then skips its\n // per-source cap and the per-recipient one carries the control alone.\n const ip = readClientIp(req.headers, {\n remoteAddress: req.socket?.remoteAddress,\n })\n // Control 1. Before the lookup, so both branches spend the same budget.\n // A store failure degrades to the in-process cap rather than refusing\n // (`consumeRateLimit` fails soft on an unreachable store, closed on\n // contention) — a Firestore blip must not lock every site's members out of\n // their own password reset.\n const attempt = await consumeMembershipRecoverAttempt({ email, ip })\n if (!attempt.allowed) {\n res.setHeader('Retry-After', String(attempt.retryAfterSeconds))\n return res.status(429).json({ error: 'Too many attempts' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const [hostSnapshot, membersQuery] = await Promise.all([\n hostRef.get(),\n hostRef\n .collection('siteMembers')\n .where('email', '==', email)\n .limit(1)\n .get(),\n ])\n const memberDoc = membersQuery.docs[0]\n // Unknown site, unknown email, suspended account (AGL-546): all take\n // the same silent-success exit — the response never distinguishes.\n if (\n !hostSnapshot.exists ||\n !memberDoc ||\n memberDoc.get('suspended') === true\n ) {\n return res.status(200).json({ ok: true })\n }\n // Control 2 — the register-then-recover composition (AGL-1966).\n //\n // `createdAt` is a server timestamp written by `membership-register.ts`.\n // A row that has none is a legacy member, not a fresh one: rows predating\n // the field must NOT be refused, so an unreadable or absent value reads as\n // \"old enough\". This guard exists to refuse a member created seconds ago,\n // which is a case where the field is always present by construction.\n const createdAtMs = (() => {\n const raw = memberDoc.get('createdAt') as\n | { toMillis?: () => number }\n | undefined\n try {\n return typeof raw?.toMillis === 'function' ? raw.toMillis() : null\n } catch {\n return null\n }\n })()\n if (\n createdAtMs !== null &&\n Date.now() - createdAtMs < RECOVER_MIN_MEMBER_AGE_MS\n ) {\n return res.status(200).json({ ok: true })\n }\n // Control 3 — the platform suppression list (AGL-1918 / AGL-2407).\n //\n // `email-suppression.ts` argues that TRANSACTIONAL mail deliberately does\n // not consult this list, because refusing a password reset over an\n // unrelated old bounce locks a real customer out of their own account.\n // That argument is about mail a signed-in human just asked us for. It does\n // not reach here, and this is the exception it does not cover:\n //\n // - The address is chosen by an ANONYMOUS caller, not by the account\n // holder. \"The human just did something\" is exactly the premise that is\n // missing on an unauthenticated endpoint.\n // - The list holds PERMANENT bounces and spam complaints only — transient\n // bounces are excluded at the webhook. A permanent bounce means the\n // mailbox does not exist, so the send cannot reach anyone; all it can do\n // is tell a provider that `aglyn.com` re-mails dead addresses. There is\n // no locked-out customer on the other side of it to protect.\n // - The reputational cost is shared by every customer's transactional\n // mail, which is precisely what AGL-1966 is about.\n //\n // The HOST list (`hosts/{hostId}/suppressions`) is deliberately NOT\n // consulted: it also holds marketing unsubscribes, and unsubscribing from\n // a store's newsletter must never stop that store's password resets.\n //\n // `isEmailSuppressed` fails CLOSED — a read that throws answers \"treat as\n // suppressed\". That adds no new outage mode here: the member lookup above\n // is a Firestore read on the same database, so a Firestore outage has\n // already taken this handler to its silent-success catch.\n if (await isEmailSuppressed(email)) {\n return res.status(200).json({ ok: true })\n }\n if (!isEmailConfigured()) {\n // Config gaps must not become an existence oracle either; log for\n // the operator and keep the visitor-facing contract.\n console.error('membership/recover: email is not configured')\n return res.status(200).json({ ok: true })\n }\n // Bound to the hash sign-in checks, the credential document's (AGL-3308).\n const token = mintPasswordResetToken(\n hostId,\n memberDoc.id,\n await readMemberPasswordHash(hostRef, memberDoc),\n )\n // `hostPublicOrigin`, not a hand-rolled apex (AGL-2195). This URL is\n // minted server-side and mailed out, so a wrong apex is not a display\n // bug — it is a live link on somebody else's domain.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n const resetUrl = `${siteBase}/recover?token=${encodeURIComponent(token)}`\n const siteName = String(\n hostSnapshot.get('displayName') ?? hostSnapshot.get('subdomain') ?? 'your site',\n )\n // White-label sender identity (White-Label Phase 3): the store's brand via\n // the one shared resolver (the copy already uses the site name).\n const branding = resolveBrandingProfile(\n (await getOrgForHost(hostId).catch(() => null))?.org as never,\n )\n // Control 4 — the per-site daily ceiling on recovery mail (AGL-1966).\n //\n // Consumed here, as late as possible, because it counts MESSAGES and not\n // requests: everything above this line can refuse without having decided\n // to send, and a counter that ticked for those would exhaust a site's\n // budget on traffic that never cost a message.\n //\n // The refusal is the silent-success exit, NOT a 429. This branch is\n // reachable only for an address that really is a member, so a visible\n // error here would be the existence oracle the whole handler is built to\n // avoid — the one place where the cheap answer is the wrong one.\n //\n // The accepted cost: during an attack on a site, a genuine member can get\n // no mail and no error. The ceiling is sized more than an order of\n // magnitude above any real site's daily resets so that reaching it is\n // evidence of the attack rather than of a busy Tuesday.\n const sendBudget = await consumeMembershipRecoverSend({ hostId })\n if (!sendBudget.allowed) {\n console.error('membership/recover: daily send ceiling reached', {\n hostId,\n retryAfterSeconds: sendBudget.retryAfterSeconds,\n })\n return res.status(200).json({ ok: true })\n }\n await sendEmail({\n to: email,\n subject: `Reset your ${siteName} password`,\n text:\n `Someone asked to reset the password for your ${siteName} ` +\n 'account. If that was you, set a new password here:\\n\\n' +\n `${resetUrl}\\n\\n` +\n 'The link works once and expires in 1 hour. If you did not ' +\n 'ask for this, you can safely ignore this email — your ' +\n 'password is unchanged.',\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership recovery',\n // Owed to the recipient by their own account: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'account',\n })\n // Cost meter (AGL-1438). Transactional, and the clearest case for why a\n // quota may not refuse one: this email is how the member gets back into\n // their account.\n await meterHostEmail(hostId)\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n // Even a backend failure stays silent-success: the enumeration\n // contract beats the visitor's error visibility here, and the send is\n // best-effort by design.\n return res.status(200).json({ ok: true })\n }\n}\n"],"names":["resolveBrandingProfile","hostPublicOrigin","consumeMembershipRecoverAttempt","consumeMembershipRecoverSend","firebaseAdmin","getOrgForHost","hostSendingIdentity","isEmailSuppressed","meterHostEmail","RECOVER_MIN_MEMBER_AGE_MS","isEmailConfigured","sendEmail","mintPasswordResetToken","readMemberPasswordHash","readClientIp","EMAIL_PATTERN","membershipRecoverHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","test","ip","headers","remoteAddress","socket","attempt","allowed","setHeader","retryAfterSeconds","hostSnapshot","firestore","app","hostRef","collection","doc","membersQuery","Promise","all","get","where","limit","memberDoc","docs","exists","ok","createdAtMs","raw","toMillis","Date","now","console","token","id","siteBase","cname","subdomain","resetUrl","encodeURIComponent","siteName","branding","catch","org","sendBudget","to","subject","text","fromName","sendingIdentity","audience","context","owedFor"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-recover.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/server'\nimport { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport {\n consumeMembershipRecoverAttempt,\n consumeMembershipRecoverSend,\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n isEmailSuppressed,\n meterHostEmail,\n RECOVER_MIN_MEMBER_AGE_MS,\n} from '@aglyn/tenant-data-admin'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { renderHostEmailWithTokens } from '@aglyn/tenant-data-admin/server/host-email-tokens'\nimport { mintPasswordResetToken } from './membership'\nimport { readMemberPasswordHash } from './member-credentials'\nimport { readClientIp } from '@aglyn/aglyn/app-utils/request-ip'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Password-recovery request (AGL-552). ALWAYS answers `200 {ok:true}` once\n * the input validates and the caller is inside its rate budget — whether or\n * not the email belongs to a member — so the endpoint can't be used to probe\n * account existence. When a member matches, a single-use one-hour token\n * (bound to the current password hash, see membership.ts) is mailed via\n * Resend with a link to the site's `/recover` route.\n *\n * ## Abuse controls (AGL-1966)\n *\n * This is an unauthenticated endpoint that makes us send mail from\n * `noreply@aglyn.com` to an address the caller chose, and it composes with\n * `membership/register` — which admits arbitrary addresses because member\n * accounts are unlimited on every plan (AGL-889) — into a mail relay. The\n * bounces from that land on the sending reputation shared by every customer's\n * transactional mail.\n *\n * Four controls, in the order they run, and the ORDER is part of each:\n *\n * 1. **Attempt caps** — per recipient and per IP, durable\n * (`membership-recover-throttle.ts`). Consumed BEFORE the member lookup so\n * a member and a non-member spend the identical budget and receive the\n * identical answer; a visible 429 from here therefore leaks nothing. The\n * per-instance `Map` this replaced reset on every cold start and was kept\n * per instance, which on Vercel is close to no limit at all (AGL-794).\n * 2. **The young-member guard** — a member row created inside\n * `RECOVER_MIN_MEMBER_AGE_MS` is not worth a reset mail. Register-then-\n * recover is the whole attack and nobody forgets a password ten minutes\n * after choosing it. Silent-success exit.\n * 3. **The platform suppression list** — see the note on the call below.\n * Silent-success exit.\n * 4. **The per-site daily send ceiling** — consumed only when a message is\n * actually about to go out. Because that branch is reachable only for real\n * members, its refusal MUST be the silent-success exit rather than an\n * error, or the ceiling becomes the oracle everything else avoids.\n *\n * What remains unbounded is stated rather than papered over: a genuine botnet\n * still gets `RECOVER_ATTEMPTS_PER_IP` per source, and an attacker who can\n * create many sites gets the per-site ceiling once per site. Host creation is\n * itself rate-limited, and bounding the botnet case needs an edge WAF, not an\n * application limiter — the same conclusion `plugin-api-rate-limit.ts` reaches.\n */\nexport const membershipRecoverHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.body?.hostId ?? '')\n const email = String(req.body?.email ?? '')\n .trim()\n .toLowerCase()\n if (!hostId || !EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Invalid request' })\n }\n // Null when nothing readable names the caller; the throttle then skips its\n // per-source cap and the per-recipient one carries the control alone.\n const ip = readClientIp(req.headers, {\n remoteAddress: req.socket?.remoteAddress,\n })\n // Control 1. Before the lookup, so both branches spend the same budget.\n // A store failure degrades to the in-process cap rather than refusing\n // (`consumeRateLimit` fails soft on an unreachable store, closed on\n // contention) — a Firestore blip must not lock every site's members out of\n // their own password reset.\n const attempt = await consumeMembershipRecoverAttempt({ email, ip })\n if (!attempt.allowed) {\n res.setHeader('Retry-After', String(attempt.retryAfterSeconds))\n return res.status(429).json({ error: 'Too many attempts' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const [hostSnapshot, membersQuery] = await Promise.all([\n hostRef.get(),\n hostRef\n .collection('siteMembers')\n .where('email', '==', email)\n .limit(1)\n .get(),\n ])\n const memberDoc = membersQuery.docs[0]\n // Unknown site, unknown email, suspended account (AGL-546): all take\n // the same silent-success exit — the response never distinguishes.\n if (\n !hostSnapshot.exists ||\n !memberDoc ||\n memberDoc.get('suspended') === true\n ) {\n return res.status(200).json({ ok: true })\n }\n // Control 2 — the register-then-recover composition (AGL-1966).\n //\n // `createdAt` is a server timestamp written by `membership-register.ts`.\n // A row that has none is a legacy member, not a fresh one: rows predating\n // the field must NOT be refused, so an unreadable or absent value reads as\n // \"old enough\". This guard exists to refuse a member created seconds ago,\n // which is a case where the field is always present by construction.\n const createdAtMs = (() => {\n const raw = memberDoc.get('createdAt') as\n | { toMillis?: () => number }\n | undefined\n try {\n return typeof raw?.toMillis === 'function' ? raw.toMillis() : null\n } catch {\n return null\n }\n })()\n if (\n createdAtMs !== null &&\n Date.now() - createdAtMs < RECOVER_MIN_MEMBER_AGE_MS\n ) {\n return res.status(200).json({ ok: true })\n }\n // Control 3 — the platform suppression list (AGL-1918 / AGL-2407).\n //\n // `email-suppression.ts` argues that TRANSACTIONAL mail deliberately does\n // not consult this list, because refusing a password reset over an\n // unrelated old bounce locks a real customer out of their own account.\n // That argument is about mail a signed-in human just asked us for. It does\n // not reach here, and this is the exception it does not cover:\n //\n // - The address is chosen by an ANONYMOUS caller, not by the account\n // holder. \"The human just did something\" is exactly the premise that is\n // missing on an unauthenticated endpoint.\n // - The list holds PERMANENT bounces and spam complaints only — transient\n // bounces are excluded at the webhook. A permanent bounce means the\n // mailbox does not exist, so the send cannot reach anyone; all it can do\n // is tell a provider that `aglyn.com` re-mails dead addresses. There is\n // no locked-out customer on the other side of it to protect.\n // - The reputational cost is shared by every customer's transactional\n // mail, which is precisely what AGL-1966 is about.\n //\n // The HOST list (`hosts/{hostId}/suppressions`) is deliberately NOT\n // consulted: it also holds marketing unsubscribes, and unsubscribing from\n // a store's newsletter must never stop that store's password resets.\n //\n // `isEmailSuppressed` fails CLOSED — a read that throws answers \"treat as\n // suppressed\". That adds no new outage mode here: the member lookup above\n // is a Firestore read on the same database, so a Firestore outage has\n // already taken this handler to its silent-success catch.\n if (await isEmailSuppressed(email)) {\n return res.status(200).json({ ok: true })\n }\n if (!isEmailConfigured()) {\n // Config gaps must not become an existence oracle either; log for\n // the operator and keep the visitor-facing contract.\n console.error('membership/recover: email is not configured')\n return res.status(200).json({ ok: true })\n }\n // Bound to the hash sign-in checks, the credential document's (AGL-3308).\n const token = mintPasswordResetToken(\n hostId,\n memberDoc.id,\n await readMemberPasswordHash(hostRef, memberDoc),\n )\n // `hostPublicOrigin`, not a hand-rolled apex (AGL-2195). This URL is\n // minted server-side and mailed out, so a wrong apex is not a display\n // bug — it is a live link on somebody else's domain.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n const resetUrl = `${siteBase}/recover?token=${encodeURIComponent(token)}`\n const siteName = String(\n hostSnapshot.get('displayName') ?? hostSnapshot.get('subdomain') ?? 'your site',\n )\n // White-label sender identity (White-Label Phase 3): the store's brand via\n // the one shared resolver (the copy already uses the site name).\n const branding = resolveBrandingProfile(\n (await getOrgForHost(hostId).catch(() => null))?.org as never,\n )\n // Control 4 — the per-site daily ceiling on recovery mail (AGL-1966).\n //\n // Consumed here, as late as possible, because it counts MESSAGES and not\n // requests: everything above this line can refuse without having decided\n // to send, and a counter that ticked for those would exhaust a site's\n // budget on traffic that never cost a message.\n //\n // The refusal is the silent-success exit, NOT a 429. This branch is\n // reachable only for an address that really is a member, so a visible\n // error here would be the existence oracle the whole handler is built to\n // avoid — the one place where the cheap answer is the wrong one.\n //\n // The accepted cost: during an attack on a site, a genuine member can get\n // no mail and no error. The ceiling is sized more than an order of\n // magnitude above any real site's daily resets so that reaching it is\n // evidence of the attack rather than of a busy Tuesday.\n const sendBudget = await consumeMembershipRecoverSend({ hostId })\n if (!sendBudget.allowed) {\n console.error('membership/recover: daily send ceiling reached', {\n hostId,\n retryAfterSeconds: sendBudget.retryAfterSeconds,\n })\n return res.status(200).json({ ok: true })\n }\n const intro = `Someone asked to reset the password for your ${siteName} account.`\n const note =\n 'The link works once and expires in 1 hour. If you did not ' +\n 'ask for this, you can safely ignore this email — your ' +\n 'password is unchanged.'\n // The `member-password-reset` site email (AGL-3370): the site's design,\n // or the built-in copy in its header and footer.\n const designed = await renderHostEmailWithTokens(\n firestore,\n hostId,\n 'member-password-reset',\n { 'site.name': siteName, 'reset.intro': intro, resetUrl, 'reset.note': note },\n siteBase ? { origin: siteBase } : {},\n ).catch(() => null)\n await sendEmail({\n to: email,\n subject: designed?.subject || `Reset your ${siteName} password`,\n text:\n designed?.text ||\n `${intro.replace(/\\.$/, '')}. If that was you, set a new password here:\\n\\n` +\n `${resetUrl}\\n\\n${note}`,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: branding.fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'membership recovery',\n // Owed to the recipient by their own account: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'account',\n })\n // Cost meter (AGL-1438). Transactional, and the clearest case for why a\n // quota may not refuse one: this email is how the member gets back into\n // their account.\n await meterHostEmail(hostId)\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n // Even a backend failure stays silent-success: the enumeration\n // contract beats the visitor's error visibility here, and the send is\n // best-effort by design.\n return res.status(200).json({ ok: true })\n }\n}\n"],"names":["resolveBrandingProfile","hostPublicOrigin","consumeMembershipRecoverAttempt","consumeMembershipRecoverSend","firebaseAdmin","getOrgForHost","hostSendingIdentity","isEmailSuppressed","meterHostEmail","RECOVER_MIN_MEMBER_AGE_MS","isEmailConfigured","sendEmail","renderHostEmailWithTokens","mintPasswordResetToken","readMemberPasswordHash","readClientIp","EMAIL_PATTERN","membershipRecoverHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","test","ip","headers","remoteAddress","socket","attempt","allowed","setHeader","retryAfterSeconds","hostSnapshot","firestore","app","hostRef","collection","doc","membersQuery","Promise","all","get","where","limit","memberDoc","docs","exists","ok","createdAtMs","raw","toMillis","Date","now","console","token","id","siteBase","cname","subdomain","resetUrl","encodeURIComponent","siteName","branding","catch","org","sendBudget","intro","note","designed","origin","to","subject","text","replace","html","fromName","sendingIdentity","audience","context","owedFor"],"mappings":"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"}
@@ -18,6 +18,7 @@ import { hostPublicOrigin } from "@aglyn/aglyn/app-utils/host-naming";
18
18
  import { mergeEmailTopics, normalizeEmailTopic, topicRequiresDoubleOptIn, EMAIL_TOPICS_COLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
19
19
  import { enrollListMember, firebaseAdmin, hostSendingIdentity, meterHostEmail, consentGroupForSite, orgDataCollectionForHost, recordPendingTopicConfirmation, resolveCampaignTouch, resolveOrgIdForHost, siteRequiresDoubleOptIn } from "@aglyn/tenant-data-admin";
20
20
  import recordCapturedContact from "@aglyn/aglyn/plugin-manager/record-captured-contact";
21
+ import { renderHostEmailWithTokens } from "@aglyn/tenant-data-admin/server/host-email-tokens";
21
22
  import { buildConfirmUrl } from "@aglyn/tenant-data-admin/server/email-unsubscribe-link";
22
23
  import { sendEmail } from "@aglyn/shared-util-email";
23
24
  import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
@@ -133,14 +134,25 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
133
134
  return true;
134
135
  }
135
136
  const stream = (_ref = topic == null ? void 0 : topic.name) != null ? _ref : 'our newsletter';
136
- const result = await sendEmail({
137
+ // The `newsletter-confirmation` site email (AGL-3370): the site's design,
138
+ // or the built-in copy in its header and footer.
139
+ const designed = await renderHostEmailWithTokens(firebaseAdmin.app().firestore(), options.hostId, 'newsletter-confirmation', {
140
+ 'stream.name': stream,
141
+ confirmUrl: url
142
+ }, siteBase ? {
143
+ origin: siteBase
144
+ } : {}).catch(()=>null);
145
+ const result = await sendEmail(_extends({
137
146
  to: options.email,
138
- subject: `Confirm your subscription`,
139
- text: `Please confirm that you want to receive ${stream} at this ` + `address:\n\n${url}\n\nThe link works for three days. If you did ` + 'not sign up, ignore this message — nothing will be sent.',
147
+ subject: (designed == null ? void 0 : designed.subject) || `Confirm your subscription`,
148
+ text: (designed == null ? void 0 : designed.text) || `Please confirm that you want to receive ${stream} at this ` + `address:\n\n${url}\n\nThe link works for three days. If you did ` + 'not sign up, ignore this message — nothing will be sent.'
149
+ }, (designed == null ? void 0 : designed.html) ? {
150
+ html: designed.html
151
+ } : {}, {
140
152
  sendingIdentity: await hostSendingIdentity(options.hostId),
141
153
  audience: 'tenant',
142
154
  context: 'newsletter confirmation'
143
- });
155
+ }));
144
156
  /*
145
157
  * Counted against the site, as TRANSACTIONAL.
146
158
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/newsletter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { hostPublicOrigin } from '@aglyn/aglyn/app-utils/host-naming'\nimport {\n mergeEmailTopics,\n normalizeEmailTopic,\n topicRequiresDoubleOptIn,\n EMAIL_TOPICS_COLLECTION,\n type EmailTopic,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n enrollListMember,\n firebaseAdmin,\n hostSendingIdentity,\n meterHostEmail,\n consentGroupForSite,\n orgDataCollectionForHost,\n recordPendingTopicConfirmation,\n resolveCampaignTouch,\n resolveOrgIdForHost,\n siteRequiresDoubleOptIn,\n} from '@aglyn/tenant-data-admin'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\nimport { buildConfirmUrl } from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * The stream a newsletter signup joins.\n *\n * The built-in id, not the campaign default: somebody typing their address\n * into a footer box is asking for the newsletter, and attributing it to\n * \"Promotions and offers\" would confirm them for a stream they did not name.\n */\nconst NEWSLETTER_TOPIC_ID = 'newsletter'\n\n/**\n * Enrolls one address into an org list's members (AGL-2499).\n *\n * The document id comes from `enrollListMember`, which is the only writer of\n * that collection — the workflow `enrollList` step reaches it too, and when\n * the two derived their own ids the same person subscribing by both routes\n * became two members of one list.\n *\n * Best-effort and silent on any problem: list enrollment rides along with\n * the newsletter signup, which must still succeed (and still upsert the\n * contact) even when the list id is stale, was mistyped into the besigner\n * prop, or belongs to an org this host cannot resolve.\n */\nasync function enrollInList(options: {\n hostId: string\n listId: string\n email: string\n name?: string\n /** The opt-in the signup itself carries — see the call site. */\n marketingConsent?: boolean\n /**\n * The disclosure key the signup rendered beside its opt-in, as the contact\n * capture received it: the membership pools over the site's consent group\n * only when it is the group's current key (AGL-3320).\n */\n disclosedConsentGroup?: string\n}): Promise<void> {\n if (!isDocumentId(options.listId)) return\n try {\n const contactsRef = await orgDataCollectionForHost(options.hostId, 'contacts')\n const listRef = contactsRef.parent?.collection('lists').doc(options.listId)\n if (!listRef) return\n const listSnapshot = await listRef.get()\n // A stale/mistyped id must not silently CREATE a list — campaign-send's\n // `list` audience would then read a list nobody set up.\n if (!listSnapshot.exists) return\n await enrollListMember({\n listRef,\n group: await consentGroupForSite(options.hostId),\n email: options.email,\n ...(options.name ? { name: options.name } : {}),\n source: 'newsletter',\n ...(options.marketingConsent ? { marketingConsent: true } : {}),\n ...(options.disclosedConsentGroup\n ? { disclosedConsentGroup: options.disclosedConsentGroup }\n : {}),\n })\n } catch (error) {\n console.error('list enrollment failed', error)\n }\n}\n\n/**\n * DOUBLE OPT-IN, when this site asks for one\n * (`docs/specs/email-competitive-gaps.md` P8).\n *\n * A footer signup is where a confirmation belongs — ActiveCampaign's shape,\n * where forms are the thing that default to it — and it is the only capture\n * path in the product where the person is present, expecting a reply, and has\n * just typed the address themselves.\n *\n * ## The confirmation message is TRANSACTIONAL\n *\n * It carries no marketing context, so it passes no frequency cap and adds no\n * unsubscribe header: somebody who just asked to subscribe is owed the answer\n * to what they asked, and gating it behind a marketing ceiling would drop\n * exactly the message that lets them out of the quarantine. The suppression\n * lists are not consulted here either — `recordPendingTopicConfirmation`\n * refuses an address that left this stream before any message is composed,\n * which is the check that matters: a signup form must not become a way to\n * mail somebody who unsubscribed by asking them again.\n *\n * A hard-bounced address is deliberately NOT refused here, and the asymmetry\n * with the opt-out above is the point. An opt-out is a stated intention; a\n * bounce is a measurement of a mailbox at a moment, and refusing to send at\n * submission time would settle it on evidence that cannot be older than the\n * question being asked. The confirmation goes out, and if it is delivered and\n * clicked, `confirmTopicSubscription` lifts the platform bounce — the round\n * trip is what proves the record stale. A complaint or an unsubscribe is\n * never lifted by that path.\n *\n * ## Best-effort, like the enrollment beside it\n *\n * A failure to send leaves the address pending and unmailable, which is the\n * safe direction: they signed up, nothing reaches them, and signing up again\n * re-sends. The signup itself still succeeds — the contact is captured with\n * its consent record either way, and losing the capture because a confirmation\n * bounced would be a worse trade for everybody.\n *\n * @returns whether the address was put in the quarantine, so the caller can\n * decide whether to enroll them on a list yet.\n */\nasync function requestConfirmation(options: {\n hostId: string\n email: string\n topics: EmailTopic[]\n /** The host document the setting was read from — see {@link loadHostGate}. */\n host: { cname?: unknown; subdomain?: unknown }\n}): Promise<boolean> {\n const topic = options.topics.find(\n (candidate) => candidate.id === NEWSLETTER_TOPIC_ID,\n )\n const { result } = await recordPendingTopicConfirmation(\n options.hostId,\n options.email,\n NEWSLETTER_TOPIC_ID,\n )\n if (result !== 'pending') return false\n try {\n const siteBase =\n hostPublicOrigin({\n cname: String(options.host.cname ?? ''),\n subdomain: String(options.host.subdomain ?? ''),\n }) ?? ''\n const url = buildConfirmUrl({\n siteBase,\n hostId: options.hostId,\n email: options.email,\n topicId: NEWSLETTER_TOPIC_ID,\n })\n // No origin or no signing secret means no link, and a confirmation\n // message with nothing to click is worse than none: the person believes\n // they have subscribed and nothing ever arrives. Said out loud rather\n // than shipped quietly, exactly as the unsubscribe seam does.\n if (!url) {\n console.warn(\n '[newsletter] confirmation not sent — no public origin or no ' +\n 'EMAIL_UNSUBSCRIBE_SECRET',\n )\n return true\n }\n const stream = topic?.name ?? 'our newsletter'\n const result = await sendEmail({\n to: options.email,\n subject: `Confirm your subscription`,\n text:\n `Please confirm that you want to receive ${stream} at this ` +\n `address:\\n\\n${url}\\n\\nThe link works for three days. If you did ` +\n 'not sign up, ignore this message — nothing will be sent.',\n sendingIdentity: await hostSendingIdentity(options.hostId),\n audience: 'tenant',\n context: 'newsletter confirmation',\n })\n /*\n * Counted against the site, as TRANSACTIONAL.\n *\n * It is a message this site sent, so it costs what a message costs and\n * the meter has to see it. `'transactional'` is the honest class: the\n * person just typed their address into a form and is waiting for the\n * answer, which is why the message carries no marketing context and no\n * unsubscribe header either. Only a delivery is counted, so a send that\n * never left is not billed as one.\n */\n if (result.sent) await meterHostEmail(options.hostId)\n } catch (error) {\n console.error('confirmation send failed', error)\n }\n return true\n}\n\n/**\n * The site's topic catalog, for the one question the signup asks of it.\n *\n * Fails soft to the built-ins, like the preference page's read: a site whose\n * org cannot be resolved still gets the four defaults, so the decision is\n * made against a catalog rather than against nothing.\n */\nasync function loadTopics(hostId: string): Promise<EmailTopic[]> {\n try {\n const orgId = await resolveOrgIdForHost(hostId)\n if (!orgId) return mergeEmailTopics(null)\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(EMAIL_TOPICS_COLLECTION)\n .get()\n return mergeEmailTopics(\n (snapshot?.docs ?? [])\n .map((doc: any) => normalizeEmailTopic(doc.id, doc.data()))\n .filter((topic: EmailTopic | null): topic is EmailTopic => !!topic),\n )\n } catch (error) {\n console.error('[newsletter] topic catalog read failed', error)\n return mergeEmailTopics(null)\n }\n}\n\n/**\n * Everything the confirmation decision needs, in one round trip.\n *\n * Three reads — the host document, the org lookup and the topic catalog — and\n * the first is issued IN PARALLEL with the other two rather than after them,\n * because none of the three depends on another's answer. A signup is a person\n * waiting for a form to come back, and serialized reads on that path are\n * latency the person feels for a decision that is usually \"no\".\n *\n * The host document is returned rather than discarded: the confirmation link\n * needs the site's public origin, which is on that same document, and reading\n * it twice would be a read per signup to recover something already in hand.\n *\n * Fails soft on the site flag, which `siteRequiresDoubleOptIn` already does —\n * a default a failed read could switch ON would quarantine every new signup\n * on a site whose owner never asked for confirmations.\n */\nasync function loadHostGate(hostId: string): Promise<{\n topics: EmailTopic[]\n siteDefault: boolean\n host: { cname?: unknown; subdomain?: unknown }\n}> {\n const [topics, siteDefault, snapshot] = await Promise.all([\n loadTopics(hostId),\n siteRequiresDoubleOptIn(hostId),\n firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n .catch(() => null),\n ])\n return {\n topics,\n siteDefault,\n host: {\n cname: snapshot?.get('cname'),\n subdomain: snapshot?.get('subdomain'),\n },\n }\n}\n\n// Best-effort per-instance flood damper.\nconst attemptsByIp = new Map<string, number[]>()\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\n\n/**\n * Newsletter opt-in (AGL-301): footer signups and checkout opt-ins land\n * in the contacts CRM with an explicit consent timestamp, feeding the\n * email-campaign audiences.\n */\nexport const newsletterHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const email = String(body.email ?? '')\n .trim()\n .toLowerCase()\n const listId = String(body.listId ?? '').trim()\n /*\n * The key of the consent-group sentence the signup rendered, when it\n * rendered one (AGL-3320). The opt-in reaches the site's whole consent\n * group only when this is the group's current key; a signup that shows no\n * sentence sends none and subscribes the person to this site alone.\n */\n const disclosedConsentGroup =\n typeof body.__consentGroup === 'string'\n ? body.__consentGroup.trim().slice(0, 64)\n : ''\n if (!hostId || !EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Enter a valid email' })\n }\n // Keeps counting under the no-address bucket rather than being skipped: an\n // unauthenticated signup endpoint that stops counting is an open write into\n // the contacts CRM.\n const ip =\n readClientIp(req.headers, { remoteAddress: req.socket?.remoteAddress }) ??\n NO_CLIENT_ADDRESS_BUCKET\n const now = Date.now()\n const attempts = (attemptsByIp.get(ip) ?? []).filter(\n (at) => now - at < 60_000,\n )\n attempts.push(now)\n attemptsByIp.set(ip, attempts)\n if (attempts.length > 10) {\n return res.status(429).json({ error: 'Too many attempts' })\n }\n try {\n // A newsletter signup is an identify moment: the visitor was anonymous\n // while they browsed and this request is the first thing that names them.\n // Both channels are asked once, here, and the later touch is credited.\n const campaignTouch = await resolveCampaignTouch({\n hostId,\n wire: body.campaignTouch,\n email,\n atMs: now,\n })\n /*\n * Reported to whichever plugin keeps people, not written by this one\n * (AGL-3080). A newsletter signup is commerce meeting somebody; what a\n * person record is, and whether this workspace keeps any, belongs to the\n * plugin that models them.\n *\n * Still awaited, because the confirmation below is deliberately after it.\n */\n await recordCapturedContact({\n /*\n * Not resolved here. The record system keys a person on the SITE they\n * were met on, so this costs the capture nothing — and resolving it\n * would put a Firestore read in front of the writer, which is the one\n * thing `recordCapturedContact` asks a door not to do.\n */\n orgId: '',\n hostId,\n identity: { email },\n interaction: {\n source: 'newsletter',\n refId: `newsletter-${now}`,\n summary: 'Subscribed to the newsletter',\n },\n // A TOUCH (AGL-3232): the opt-in lands on the lead the site is\n // working for this address when it holds one, and on the contact\n // otherwise — the consent stays with the one record the person is.\n surface: 'touch',\n // The stage the name is for (AGL-2612): somebody who asked to hear\n // from the site and nothing more yet. A floor, so a customer who\n // subscribes stays a customer.\n lifecycleFloor: 'subscriber',\n marketingConsent: true,\n ...(disclosedConsentGroup ? { disclosedConsentGroup } : {}),\n // Where the visitor ARRIVED from — a fact about this visit and not\n // about the person, which is what `detail` carries.\n ...(campaignTouch ? { detail: { campaignTouch } } : {}),\n })\n /*\n * The confirmation, when this site asks for one.\n *\n * AFTER the contact upsert and never instead of it. The person typed\n * their address and ticked a box, and that consent record is a fact\n * whether or not they go on to click a link — withholding the capture\n * until they do would lose the record of what they actually did. What the\n * confirmation gates is the SEND, on the person's own topic entry, which\n * is where `filterTopicSendable` reads it.\n */\n const { topics, siteDefault, host } = await loadHostGate(hostId)\n const confirming =\n topicRequiresDoubleOptIn(\n topics.find((topic) => topic.id === NEWSLETTER_TOPIC_ID),\n siteDefault,\n ) && (await requestConfirmation({ hostId, email, topics, host }))\n if (listId) {\n /*\n * The same basis the contact upsert records, on the membership too.\n *\n * A list membership had no consent field at all, so `audience: 'list'`\n * gave the send-time join nothing to read even for the one audience\n * whose members literally asked for a newsletter\n * (`docs/specs/email-overhaul.md` §1d). This is a DECLARED opt-in and\n * not an inference from an act: the request this handler serves is\n * \"subscribe me\", which is the checkbox.\n */\n await enrollInList({\n hostId,\n listId,\n email,\n marketingConsent: true,\n ...(disclosedConsentGroup ? { disclosedConsentGroup } : {}),\n })\n }\n /*\n * The caller is told which of the two things happened, so the signup form\n * can say \"check your email\" rather than \"you're subscribed\".\n *\n * A form that reported success identically either way would be the site\n * telling somebody they are on the list while the send path refuses them,\n * and the only way they could find out is by noticing that nothing\n * arrives.\n */\n return res.status(200).json({ ok: true, confirmationRequired: confirming })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Signup failed' })\n }\n}\n"],"names":["hostPublicOrigin","mergeEmailTopics","normalizeEmailTopic","topicRequiresDoubleOptIn","EMAIL_TOPICS_COLLECTION","enrollListMember","firebaseAdmin","hostSendingIdentity","meterHostEmail","consentGroupForSite","orgDataCollectionForHost","recordPendingTopicConfirmation","resolveCampaignTouch","resolveOrgIdForHost","siteRequiresDoubleOptIn","recordCapturedContact","buildConfirmUrl","sendEmail","isDocumentId","EMAIL_PATTERN","NEWSLETTER_TOPIC_ID","enrollInList","options","listId","contactsRef","hostId","listRef","parent","collection","doc","listSnapshot","get","exists","group","email","name","source","marketingConsent","disclosedConsentGroup","error","console","requestConfirmation","topic","topics","find","candidate","id","result","siteBase","cname","String","host","subdomain","url","topicId","warn","stream","to","subject","text","sendingIdentity","audience","context","sent","loadTopics","orgId","snapshot","app","firestore","docs","map","data","filter","loadHostGate","siteDefault","Promise","all","catch","attemptsByIp","Map","NO_CLIENT_ADDRESS_BUCKET","readClientIp","newsletterHandler","req","res","body","method","status","json","JSON","parse","trim","toLowerCase","__consentGroup","slice","test","ip","headers","remoteAddress","socket","now","Date","attempts","at","push","set","length","campaignTouch","wire","atMs","identity","interaction","refId","summary","surface","lifecycleFloor","detail","confirming","ok","confirmationRequired"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/newsletter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { hostPublicOrigin } from '@aglyn/aglyn/app-utils/host-naming'\nimport {\n mergeEmailTopics,\n normalizeEmailTopic,\n topicRequiresDoubleOptIn,\n EMAIL_TOPICS_COLLECTION,\n type EmailTopic,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport {\n enrollListMember,\n firebaseAdmin,\n hostSendingIdentity,\n meterHostEmail,\n consentGroupForSite,\n orgDataCollectionForHost,\n recordPendingTopicConfirmation,\n resolveCampaignTouch,\n resolveOrgIdForHost,\n siteRequiresDoubleOptIn,\n} from '@aglyn/tenant-data-admin'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\nimport { renderHostEmailWithTokens } from '@aglyn/tenant-data-admin/server/host-email-tokens'\nimport { buildConfirmUrl } from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * The stream a newsletter signup joins.\n *\n * The built-in id, not the campaign default: somebody typing their address\n * into a footer box is asking for the newsletter, and attributing it to\n * \"Promotions and offers\" would confirm them for a stream they did not name.\n */\nconst NEWSLETTER_TOPIC_ID = 'newsletter'\n\n/**\n * Enrolls one address into an org list's members (AGL-2499).\n *\n * The document id comes from `enrollListMember`, which is the only writer of\n * that collection — the workflow `enrollList` step reaches it too, and when\n * the two derived their own ids the same person subscribing by both routes\n * became two members of one list.\n *\n * Best-effort and silent on any problem: list enrollment rides along with\n * the newsletter signup, which must still succeed (and still upsert the\n * contact) even when the list id is stale, was mistyped into the besigner\n * prop, or belongs to an org this host cannot resolve.\n */\nasync function enrollInList(options: {\n hostId: string\n listId: string\n email: string\n name?: string\n /** The opt-in the signup itself carries — see the call site. */\n marketingConsent?: boolean\n /**\n * The disclosure key the signup rendered beside its opt-in, as the contact\n * capture received it: the membership pools over the site's consent group\n * only when it is the group's current key (AGL-3320).\n */\n disclosedConsentGroup?: string\n}): Promise<void> {\n if (!isDocumentId(options.listId)) return\n try {\n const contactsRef = await orgDataCollectionForHost(options.hostId, 'contacts')\n const listRef = contactsRef.parent?.collection('lists').doc(options.listId)\n if (!listRef) return\n const listSnapshot = await listRef.get()\n // A stale/mistyped id must not silently CREATE a list — campaign-send's\n // `list` audience would then read a list nobody set up.\n if (!listSnapshot.exists) return\n await enrollListMember({\n listRef,\n group: await consentGroupForSite(options.hostId),\n email: options.email,\n ...(options.name ? { name: options.name } : {}),\n source: 'newsletter',\n ...(options.marketingConsent ? { marketingConsent: true } : {}),\n ...(options.disclosedConsentGroup\n ? { disclosedConsentGroup: options.disclosedConsentGroup }\n : {}),\n })\n } catch (error) {\n console.error('list enrollment failed', error)\n }\n}\n\n/**\n * DOUBLE OPT-IN, when this site asks for one\n * (`docs/specs/email-competitive-gaps.md` P8).\n *\n * A footer signup is where a confirmation belongs — ActiveCampaign's shape,\n * where forms are the thing that default to it — and it is the only capture\n * path in the product where the person is present, expecting a reply, and has\n * just typed the address themselves.\n *\n * ## The confirmation message is TRANSACTIONAL\n *\n * It carries no marketing context, so it passes no frequency cap and adds no\n * unsubscribe header: somebody who just asked to subscribe is owed the answer\n * to what they asked, and gating it behind a marketing ceiling would drop\n * exactly the message that lets them out of the quarantine. The suppression\n * lists are not consulted here either — `recordPendingTopicConfirmation`\n * refuses an address that left this stream before any message is composed,\n * which is the check that matters: a signup form must not become a way to\n * mail somebody who unsubscribed by asking them again.\n *\n * A hard-bounced address is deliberately NOT refused here, and the asymmetry\n * with the opt-out above is the point. An opt-out is a stated intention; a\n * bounce is a measurement of a mailbox at a moment, and refusing to send at\n * submission time would settle it on evidence that cannot be older than the\n * question being asked. The confirmation goes out, and if it is delivered and\n * clicked, `confirmTopicSubscription` lifts the platform bounce — the round\n * trip is what proves the record stale. A complaint or an unsubscribe is\n * never lifted by that path.\n *\n * ## Best-effort, like the enrollment beside it\n *\n * A failure to send leaves the address pending and unmailable, which is the\n * safe direction: they signed up, nothing reaches them, and signing up again\n * re-sends. The signup itself still succeeds — the contact is captured with\n * its consent record either way, and losing the capture because a confirmation\n * bounced would be a worse trade for everybody.\n *\n * @returns whether the address was put in the quarantine, so the caller can\n * decide whether to enroll them on a list yet.\n */\nasync function requestConfirmation(options: {\n hostId: string\n email: string\n topics: EmailTopic[]\n /** The host document the setting was read from — see {@link loadHostGate}. */\n host: { cname?: unknown; subdomain?: unknown }\n}): Promise<boolean> {\n const topic = options.topics.find(\n (candidate) => candidate.id === NEWSLETTER_TOPIC_ID,\n )\n const { result } = await recordPendingTopicConfirmation(\n options.hostId,\n options.email,\n NEWSLETTER_TOPIC_ID,\n )\n if (result !== 'pending') return false\n try {\n const siteBase =\n hostPublicOrigin({\n cname: String(options.host.cname ?? ''),\n subdomain: String(options.host.subdomain ?? ''),\n }) ?? ''\n const url = buildConfirmUrl({\n siteBase,\n hostId: options.hostId,\n email: options.email,\n topicId: NEWSLETTER_TOPIC_ID,\n })\n // No origin or no signing secret means no link, and a confirmation\n // message with nothing to click is worse than none: the person believes\n // they have subscribed and nothing ever arrives. Said out loud rather\n // than shipped quietly, exactly as the unsubscribe seam does.\n if (!url) {\n console.warn(\n '[newsletter] confirmation not sent — no public origin or no ' +\n 'EMAIL_UNSUBSCRIBE_SECRET',\n )\n return true\n }\n const stream = topic?.name ?? 'our newsletter'\n // The `newsletter-confirmation` site email (AGL-3370): the site's design,\n // or the built-in copy in its header and footer.\n const designed = await renderHostEmailWithTokens(\n firebaseAdmin.app().firestore(),\n options.hostId,\n 'newsletter-confirmation',\n { 'stream.name': stream, confirmUrl: url },\n siteBase ? { origin: siteBase } : {},\n ).catch(() => null)\n const result = await sendEmail({\n to: options.email,\n subject: designed?.subject || `Confirm your subscription`,\n text:\n designed?.text ||\n `Please confirm that you want to receive ${stream} at this ` +\n `address:\\n\\n${url}\\n\\nThe link works for three days. If you did ` +\n 'not sign up, ignore this message — nothing will be sent.',\n ...(designed?.html ? { html: designed.html } : {}),\n sendingIdentity: await hostSendingIdentity(options.hostId),\n audience: 'tenant',\n context: 'newsletter confirmation',\n })\n /*\n * Counted against the site, as TRANSACTIONAL.\n *\n * It is a message this site sent, so it costs what a message costs and\n * the meter has to see it. `'transactional'` is the honest class: the\n * person just typed their address into a form and is waiting for the\n * answer, which is why the message carries no marketing context and no\n * unsubscribe header either. Only a delivery is counted, so a send that\n * never left is not billed as one.\n */\n if (result.sent) await meterHostEmail(options.hostId)\n } catch (error) {\n console.error('confirmation send failed', error)\n }\n return true\n}\n\n/**\n * The site's topic catalog, for the one question the signup asks of it.\n *\n * Fails soft to the built-ins, like the preference page's read: a site whose\n * org cannot be resolved still gets the four defaults, so the decision is\n * made against a catalog rather than against nothing.\n */\nasync function loadTopics(hostId: string): Promise<EmailTopic[]> {\n try {\n const orgId = await resolveOrgIdForHost(hostId)\n if (!orgId) return mergeEmailTopics(null)\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(EMAIL_TOPICS_COLLECTION)\n .get()\n return mergeEmailTopics(\n (snapshot?.docs ?? [])\n .map((doc: any) => normalizeEmailTopic(doc.id, doc.data()))\n .filter((topic: EmailTopic | null): topic is EmailTopic => !!topic),\n )\n } catch (error) {\n console.error('[newsletter] topic catalog read failed', error)\n return mergeEmailTopics(null)\n }\n}\n\n/**\n * Everything the confirmation decision needs, in one round trip.\n *\n * Three reads — the host document, the org lookup and the topic catalog — and\n * the first is issued IN PARALLEL with the other two rather than after them,\n * because none of the three depends on another's answer. A signup is a person\n * waiting for a form to come back, and serialized reads on that path are\n * latency the person feels for a decision that is usually \"no\".\n *\n * The host document is returned rather than discarded: the confirmation link\n * needs the site's public origin, which is on that same document, and reading\n * it twice would be a read per signup to recover something already in hand.\n *\n * Fails soft on the site flag, which `siteRequiresDoubleOptIn` already does —\n * a default a failed read could switch ON would quarantine every new signup\n * on a site whose owner never asked for confirmations.\n */\nasync function loadHostGate(hostId: string): Promise<{\n topics: EmailTopic[]\n siteDefault: boolean\n host: { cname?: unknown; subdomain?: unknown }\n}> {\n const [topics, siteDefault, snapshot] = await Promise.all([\n loadTopics(hostId),\n siteRequiresDoubleOptIn(hostId),\n firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n .catch(() => null),\n ])\n return {\n topics,\n siteDefault,\n host: {\n cname: snapshot?.get('cname'),\n subdomain: snapshot?.get('subdomain'),\n },\n }\n}\n\n// Best-effort per-instance flood damper.\nconst attemptsByIp = new Map<string, number[]>()\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\n\n/**\n * Newsletter opt-in (AGL-301): footer signups and checkout opt-ins land\n * in the contacts CRM with an explicit consent timestamp, feeding the\n * email-campaign audiences.\n */\nexport const newsletterHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const email = String(body.email ?? '')\n .trim()\n .toLowerCase()\n const listId = String(body.listId ?? '').trim()\n /*\n * The key of the consent-group sentence the signup rendered, when it\n * rendered one (AGL-3320). The opt-in reaches the site's whole consent\n * group only when this is the group's current key; a signup that shows no\n * sentence sends none and subscribes the person to this site alone.\n */\n const disclosedConsentGroup =\n typeof body.__consentGroup === 'string'\n ? body.__consentGroup.trim().slice(0, 64)\n : ''\n if (!hostId || !EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Enter a valid email' })\n }\n // Keeps counting under the no-address bucket rather than being skipped: an\n // unauthenticated signup endpoint that stops counting is an open write into\n // the contacts CRM.\n const ip =\n readClientIp(req.headers, { remoteAddress: req.socket?.remoteAddress }) ??\n NO_CLIENT_ADDRESS_BUCKET\n const now = Date.now()\n const attempts = (attemptsByIp.get(ip) ?? []).filter(\n (at) => now - at < 60_000,\n )\n attempts.push(now)\n attemptsByIp.set(ip, attempts)\n if (attempts.length > 10) {\n return res.status(429).json({ error: 'Too many attempts' })\n }\n try {\n // A newsletter signup is an identify moment: the visitor was anonymous\n // while they browsed and this request is the first thing that names them.\n // Both channels are asked once, here, and the later touch is credited.\n const campaignTouch = await resolveCampaignTouch({\n hostId,\n wire: body.campaignTouch,\n email,\n atMs: now,\n })\n /*\n * Reported to whichever plugin keeps people, not written by this one\n * (AGL-3080). A newsletter signup is commerce meeting somebody; what a\n * person record is, and whether this workspace keeps any, belongs to the\n * plugin that models them.\n *\n * Still awaited, because the confirmation below is deliberately after it.\n */\n await recordCapturedContact({\n /*\n * Not resolved here. The record system keys a person on the SITE they\n * were met on, so this costs the capture nothing — and resolving it\n * would put a Firestore read in front of the writer, which is the one\n * thing `recordCapturedContact` asks a door not to do.\n */\n orgId: '',\n hostId,\n identity: { email },\n interaction: {\n source: 'newsletter',\n refId: `newsletter-${now}`,\n summary: 'Subscribed to the newsletter',\n },\n // A TOUCH (AGL-3232): the opt-in lands on the lead the site is\n // working for this address when it holds one, and on the contact\n // otherwise — the consent stays with the one record the person is.\n surface: 'touch',\n // The stage the name is for (AGL-2612): somebody who asked to hear\n // from the site and nothing more yet. A floor, so a customer who\n // subscribes stays a customer.\n lifecycleFloor: 'subscriber',\n marketingConsent: true,\n ...(disclosedConsentGroup ? { disclosedConsentGroup } : {}),\n // Where the visitor ARRIVED from — a fact about this visit and not\n // about the person, which is what `detail` carries.\n ...(campaignTouch ? { detail: { campaignTouch } } : {}),\n })\n /*\n * The confirmation, when this site asks for one.\n *\n * AFTER the contact upsert and never instead of it. The person typed\n * their address and ticked a box, and that consent record is a fact\n * whether or not they go on to click a link — withholding the capture\n * until they do would lose the record of what they actually did. What the\n * confirmation gates is the SEND, on the person's own topic entry, which\n * is where `filterTopicSendable` reads it.\n */\n const { topics, siteDefault, host } = await loadHostGate(hostId)\n const confirming =\n topicRequiresDoubleOptIn(\n topics.find((topic) => topic.id === NEWSLETTER_TOPIC_ID),\n siteDefault,\n ) && (await requestConfirmation({ hostId, email, topics, host }))\n if (listId) {\n /*\n * The same basis the contact upsert records, on the membership too.\n *\n * A list membership had no consent field at all, so `audience: 'list'`\n * gave the send-time join nothing to read even for the one audience\n * whose members literally asked for a newsletter\n * (`docs/specs/email-overhaul.md` §1d). This is a DECLARED opt-in and\n * not an inference from an act: the request this handler serves is\n * \"subscribe me\", which is the checkbox.\n */\n await enrollInList({\n hostId,\n listId,\n email,\n marketingConsent: true,\n ...(disclosedConsentGroup ? { disclosedConsentGroup } : {}),\n })\n }\n /*\n * The caller is told which of the two things happened, so the signup form\n * can say \"check your email\" rather than \"you're subscribed\".\n *\n * A form that reported success identically either way would be the site\n * telling somebody they are on the list while the send path refuses them,\n * and the only way they could find out is by noticing that nothing\n * arrives.\n */\n return res.status(200).json({ ok: true, confirmationRequired: confirming })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Signup failed' })\n }\n}\n"],"names":["hostPublicOrigin","mergeEmailTopics","normalizeEmailTopic","topicRequiresDoubleOptIn","EMAIL_TOPICS_COLLECTION","enrollListMember","firebaseAdmin","hostSendingIdentity","meterHostEmail","consentGroupForSite","orgDataCollectionForHost","recordPendingTopicConfirmation","resolveCampaignTouch","resolveOrgIdForHost","siteRequiresDoubleOptIn","recordCapturedContact","renderHostEmailWithTokens","buildConfirmUrl","sendEmail","isDocumentId","EMAIL_PATTERN","NEWSLETTER_TOPIC_ID","enrollInList","options","listId","contactsRef","hostId","listRef","parent","collection","doc","listSnapshot","get","exists","group","email","name","source","marketingConsent","disclosedConsentGroup","error","console","requestConfirmation","topic","topics","find","candidate","id","result","siteBase","cname","String","host","subdomain","url","topicId","warn","stream","designed","app","firestore","confirmUrl","origin","catch","to","subject","text","html","sendingIdentity","audience","context","sent","loadTopics","orgId","snapshot","docs","map","data","filter","loadHostGate","siteDefault","Promise","all","attemptsByIp","Map","NO_CLIENT_ADDRESS_BUCKET","readClientIp","newsletterHandler","req","res","body","method","status","json","JSON","parse","trim","toLowerCase","__consentGroup","slice","test","ip","headers","remoteAddress","socket","now","Date","attempts","at","push","set","length","campaignTouch","wire","atMs","identity","interaction","refId","summary","surface","lifecycleFloor","detail","confirming","ok","confirmationRequired"],"mappings":"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"}
@@ -16,13 +16,13 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
16
16
  * limitations under the License.
17
17
  */ import * as Aglyn from "@aglyn/aglyn/server";
18
18
  import { firebaseAdmin, getOrgForHost, hostSendingIdentity, meterHostEmail } from "@aglyn/tenant-data-admin";
19
- import { isDeferrableSendResult, isEmailConfigured, loadHostEmail, renderLoadedHostEmail, sendEmail } from "@aglyn/shared-util-email";
20
- import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-referenced-components";
19
+ import { isDeferrableSendResult, isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
21
20
  // The leaf, not the barrel: the console's `x-cron-secret` door asks the
22
21
  // site's lockdown directly (AGL-3356) — core's registry has no resolver in
23
22
  // the console process — and a spec that substitutes the barrel must still
24
23
  // reach the real verdict.
25
24
  import { siteLockdownJobGate } from "@aglyn/tenant-data-admin/server/tenant-write-lockdown";
25
+ import { loadHostEmailWithTokens, renderLoadedHostEmailWithTokens } from "@aglyn/tenant-data-admin/server/host-email-tokens";
26
26
  import { CHECKOUT_GIVE_UP_AFTER_MS as GIVE_UP_AFTER_MS, CHECKOUT_REMIND_AFTER_MS as REMIND_AFTER_MS } from "../model/checkout-recovery.js";
27
27
  /**
28
28
  * The abandoned-checkout pass itself, separated from its HTTP door (AGL-2227).
@@ -129,16 +129,13 @@ import { CHECKOUT_GIVE_UP_AFTER_MS as GIVE_UP_AFTER_MS, CHECKOUT_REMIND_AFTER_MS
129
129
  if (!entitledHosts.get(hostId)) continue;
130
130
  let loaded = templateCache.get(hostId);
131
131
  if (loaded === undefined) {
132
- loaded = await loadHostEmail(firestore, hostId, 'abandoned-cart', {
133
- // The site's header and footer blocks, grafted once per site for the
134
- // whole sweep (AGL-3287).
135
- compose: composeHostComponentNodes
136
- });
132
+ loaded = await // footer, once per site for the whole batch (AGL-3287, AGL-3370).
133
+ loadHostEmailWithTokens(firestore, hostId, 'abandoned-cart');
137
134
  templateCache.set(hostId, loaded);
138
135
  }
139
- const designed = loaded ? renderLoadedHostEmail(loaded, {
136
+ const designed = loaded ? renderLoadedHostEmailWithTokens(loaded, {
140
137
  'cart.url': String((_data_resumeUrl = data.resumeUrl) != null ? _data_resumeUrl : '')
141
- }, Aglyn.sanitizeAuthorHtml) : null;
138
+ }) : null;
142
139
  /*
143
140
  * MARKETING, and `'bulk'` priority, which this sweep is entitled to
144
141
  * precisely because it is resumable: a checkout left unstamped below is
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/process-abandoned.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport {\n isDeferrableSendResult,\n isEmailConfigured,\n loadHostEmail,\n renderLoadedHostEmail,\n sendEmail,\n type LoadedHostEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n} from '@aglyn/aglyn/server'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\n// The leaf, not the barrel: the console's `x-cron-secret` door asks the\n// site's lockdown directly (AGL-3356) — core's registry has no resolver in\n// the console process — and a spec that substitutes the barrel must still\n// reach the real verdict.\nimport { siteLockdownJobGate } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\n\nimport {\n CHECKOUT_GIVE_UP_AFTER_MS as GIVE_UP_AFTER_MS,\n CHECKOUT_REMIND_AFTER_MS as REMIND_AFTER_MS,\n type CheckoutRecoveryState,\n} from '../model/checkout-recovery'\n\n/** One pass over the open checkouts. Bounded (200) and idempotent. */\nexport interface AbandonedScanResult {\n scanned: number\n sent: number\n /** Rows left untouched because their site is locked (AGL-2495). */\n skippedLocked: number\n}\n\n/**\n * The abandoned-checkout pass itself, separated from its HTTP door (AGL-2227).\n *\n * It was only ever reachable through `processAbandonedHandler`, which is\n * `x-cron-secret`-gated — and **nothing has ever POSTed to it**. It is not in\n * `.github/workflows/scheduled-crons.yml`, there is no `crons` key in\n * `vercel.json`, and it was not a `registerPluginJob`. `server.ts` described\n * these as \"the scheduler-driven jobs\" in a comment, which is how a dead\n * feature stays invisible: the code asserted the wiring rather than having it.\n *\n * So `abandonedCart` — a Pro-tier entitlement, sold on /pricing, with a\n * console row added by AGL-2081 telling the merchant their template needs it —\n * had never sent one email. Exporting the scan lets `server.ts` register it on\n * the platform job beat (the every-minute `pluginJobsBeat` that AGL-2176\n * proved out) while the HTTP door stays for manual/ops invocation and for the\n * console's \"send now\" control.\n */\nexport async function scanAbandonedCheckouts(\n /**\n * The lockdown gate, injected by whoever drives the pass (AGL-2495). NOT\n * optional: a default would make \"forgot to thread it\" compile, which is\n * the whole failure this parameter exists to make impossible.\n */\n gate: PluginJobHostGate,\n): Promise<AbandonedScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const now = Date.now()\n // Collection-group over every host's checkouts.\n const openCheckouts = await firestore\n .collectionGroup('checkouts')\n .where('status', '==', 'open')\n .limit(200)\n .get()\n let sent = 0\n const entitledHosts = new Map<string, boolean>()\n // White-label brand per host (White-Label Phase 3): resolved once per host\n // alongside the entitlement, from the same org doc, through the one shared\n // resolver — so the recovery email's sender reads as the store's brand.\n const brandingByHost = new Map<string, Aglyn.ResolvedBrandingProfile>()\n /*\n * One identity resolution per SITE, not per message.\n *\n * The same reason the branding map above exists: this sweep runs across\n * every site, and an uncached resolution here would be two Firestore reads\n * per recipient rather than per host. Scoped to the run, so a domain\n * un-verified by the re-check sweep is picked up on the next one.\n */\n const identityByHost = new Map<\n string,\n Awaited<ReturnType<typeof hostSendingIdentity>>\n >()\n /** Each site's public origin, for the unsubscribe link on the reminder. */\n const siteBaseByHost = new Map<string, string>()\n /**\n * Each site's consent group, off the same org read as its entitlement, so\n * the gate honors an unsubscribe from any site the org declared one sender\n * with this one, and the org's word on whether its sites wait for each\n * other's confirmation click.\n */\n const consentGroupByHost = new Map<string, Aglyn.ConsentGroup>()\n // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmail | null>()\n let skippedLocked = 0\n for (const docSnapshot of openCheckouts.docs) {\n const data = docSnapshot.data() as any\n // `hosts/{hostId}/checkouts/{id}` — the grandparent is the host.\n const hostId = docSnapshot.ref.parent.parent?.id\n if (!hostId) continue\n // LOCKDOWN (AGL-2495), and it is the FIRST thing in the loop body rather\n // than sitting next to the email below, because the `status: 'expired'`\n // stamp further down is itself a write for this host. Reordered for that\n // reason: the cheap filters used to run first, and one of them wrote.\n //\n // SKIPPED, NOT DROPPED: `continue` leaves the checkout `open` and\n // unstamped, so the reminder is sent — or the row expired — on the first\n // beat after the lift. Nothing here marks a checkout done, so declining\n // to act IS leaving it for later.\n if (await gate.isLocked(hostId)) {\n skippedLocked += 1\n continue\n }\n const createdAtMs = Number(data.createdAtMs ?? 0)\n // Expiry comes BEFORE the email and reminded skips. This pass reads 200\n // open checkouts, and a checkout that never had an address, or was\n // already reminded, used to be skipped while still `open` — forever — so\n // enough of them would fill every pass and starve the ones still owed a\n // reminder. Past the give-up age every open checkout closes, whatever\n // else it is.\n if (now - createdAtMs > GIVE_UP_AFTER_MS) {\n await docSnapshot.ref\n .set({ status: 'expired' }, { merge: true })\n .catch(() => undefined)\n continue\n }\n if (!data.email || data.remindedAtMs) continue\n if (now - createdAtMs < REMIND_AFTER_MS) continue\n if (!entitledHosts.has(hostId)) {\n const org = await getOrgForHost(hostId).catch(() => null)\n entitledHosts.set(\n hostId,\n Aglyn.checkEntitlement(org?.org as any, 'abandonedCart'),\n )\n brandingByHost.set(hostId, Aglyn.resolveBrandingProfile(org?.org as any))\n consentGroupByHost.set(\n hostId,\n Aglyn.consentGroupForHost(\n (org?.org as Record<string, unknown> | undefined) ?? null,\n hostId,\n ),\n )\n // The site's own origin, for the unsubscribe link. Resolved once per\n // host for the same reason the branding beside it is: this sweep is a\n // `collectionGroup` over every site's checkouts, and a read per\n // reminder would be a read per shopper.\n const hostSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .get()\n .catch(() => null)\n siteBaseByHost.set(\n hostId,\n Aglyn.hostPublicOrigin({\n cname: hostSnapshot?.get('cname'),\n subdomain: hostSnapshot?.get('subdomain'),\n }) ?? '',\n )\n }\n if (!entitledHosts.get(hostId)) continue\n let loaded = templateCache.get(hostId)\n if (loaded === undefined) {\n loaded = await loadHostEmail(firestore, hostId, 'abandoned-cart', {\n // The site's header and footer blocks, grafted once per site for the\n // whole sweep (AGL-3287).\n compose: composeHostComponentNodes,\n })\n templateCache.set(hostId, loaded)\n }\n const designed = loaded\n ? renderLoadedHostEmail(\n loaded,\n { 'cart.url': String(data.resumeUrl ?? '') },\n Aglyn.sanitizeAuthorHtml,\n )\n : null\n /*\n * MARKETING, and `'bulk'` priority, which this sweep is entitled to\n * precisely because it is resumable: a checkout left unstamped below is\n * re-scanned on the next beat, so a refusal means \"not this hour\" rather\n * than a reminder nobody ever gets.\n *\n * The gate adds the unsubscribe header pair and a visible link, checks\n * both suppression lists, and counts this against how much mail the\n * shopper has had from this site today.\n */\n const consentGroup =\n consentGroupByHost.get(hostId) ?? Aglyn.soloConsentGroup(hostId)\n const result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? 'You left something in your cart',\n text:\n designed?.text ||\n 'Your cart is still waiting — pick up where you left off:\\n\\n' +\n `${data.resumeUrl ?? ''}\\n\\n` +\n 'Your items are held but not reserved, so they may sell out.',\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: brandingByHost.get(hostId)?.fromName,\n sendingIdentity: await hostSendingIdentity(hostId, identityByHost),\n audience: 'tenant',\n context: 'abandoned cart',\n priority: 'bulk',\n /*\n * A cart reminder is promotional mail — the \"Promotions and offers\"\n * stream, which is also where a campaign lands by default. Declared so\n * a shopper who left that stream stops receiving these; the gate asks\n * the topic only for callers that name one.\n */\n marketing: {\n hostId,\n siteBase: siteBaseByHost.get(hostId) ?? '',\n topicId: Aglyn.EMAIL_TOPIC_MARKETING,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n /*\n * SKIPPED, NOT DROPPED — the lockdown rule above, applied to the two\n * refusals a later beat can pass.\n *\n * `remindedAtMs` is what retires a checkout from this sweep. Stamping it\n * after the hourly ceiling or the frequency cap refused would turn a\n * deferral into a reminder nobody ever gets; NOT stamping it after a\n * suppression or a rejection would re-read the same doomed row on every\n * beat until it crowded out the checkouts that could still be reminded.\n * `isDeferrableSendResult` is the one place that distinction is made.\n */\n if (isDeferrableSendResult(result)) continue\n // Cost meter (AGL-1438). One reminder per abandoned checkout, triggered\n // by that shopper's own action rather than composed as a broadcast, so\n // it counts toward cost without entering the campaign cap. On the\n // DELIVERED message only: a suppressed recipient produced no message, so\n // there is no cost to record.\n if (result.sent) await meterHostEmail(hostId)\n // `recoveryState` beside it, so the console's recovery queue counts this\n // checkout as reminded by query rather than by reading it (AGL-3321).\n const reminded: CheckoutRecoveryState = 'reminded'\n await docSnapshot.ref\n .set({ remindedAtMs: now, recoveryState: reminded }, { merge: true })\n .catch(() => undefined)\n if (result.sent) sent += 1\n }\n return { scanned: openCheckouts.size, sent, skippedLocked }\n}\n\n/**\n * HTTP door for the pass above (AGL-323). Kept `x-cron-secret`-gated so an\n * external scheduler or an operator can still drive it; the job registration\n * in `server.ts` is what makes it actually run.\n */\nexport const processAbandonedHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret) {\n return res.status(501).json({ error: 'Not configured (CRON_SECRET).' })\n }\n if (req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthorized' })\n }\n if (!isEmailConfigured()) {\n return res.status(501).json({ error: 'Email is not configured.' })\n }\n try {\n // The manual door asks the same question the beat does (AGL-2495): a\n // forced pass is still a pass, and `x-cron-secret` is not an argument\n // for mailing a suspended site's shoppers.\n return res.status(200).json(await scanAbandonedCheckouts(siteLockdownJobGate()))\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["Aglyn","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","isDeferrableSendResult","isEmailConfigured","loadHostEmail","renderLoadedHostEmail","sendEmail","composeHostComponentNodes","siteLockdownJobGate","CHECKOUT_GIVE_UP_AFTER_MS","GIVE_UP_AFTER_MS","CHECKOUT_REMIND_AFTER_MS","REMIND_AFTER_MS","scanAbandonedCheckouts","gate","firestore","app","now","Date","openCheckouts","collectionGroup","where","limit","get","sent","entitledHosts","Map","brandingByHost","identityByHost","siteBaseByHost","consentGroupByHost","templateCache","skippedLocked","docSnapshot","docs","data","hostId","ref","parent","id","isLocked","createdAtMs","Number","set","status","merge","catch","undefined","email","remindedAtMs","has","org","checkEntitlement","resolveBrandingProfile","consentGroupForHost","hostSnapshot","collection","doc","hostPublicOrigin","cname","subdomain","loaded","compose","designed","String","resumeUrl","sanitizeAuthorHtml","consentGroup","soloConsentGroup","result","to","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","siteBase","topicId","EMAIL_TOPIC_MARKETING","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","reminded","recoveryState","scanned","size","processAbandonedHandler","req","res","method","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/process-abandoned.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport {\n isDeferrableSendResult,\n isEmailConfigured,\n sendEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n} from '@aglyn/aglyn/server'\n// The leaf, not the barrel: the console's `x-cron-secret` door asks the\n// site's lockdown directly (AGL-3356) — core's registry has no resolver in\n// the console process — and a spec that substitutes the barrel must still\n// reach the real verdict.\nimport { siteLockdownJobGate } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\nimport {\n loadHostEmailWithTokens,\n renderLoadedHostEmailWithTokens,\n type LoadedHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin/server/host-email-tokens'\n\nimport {\n CHECKOUT_GIVE_UP_AFTER_MS as GIVE_UP_AFTER_MS,\n CHECKOUT_REMIND_AFTER_MS as REMIND_AFTER_MS,\n type CheckoutRecoveryState,\n} from '../model/checkout-recovery'\n\n/** One pass over the open checkouts. Bounded (200) and idempotent. */\nexport interface AbandonedScanResult {\n scanned: number\n sent: number\n /** Rows left untouched because their site is locked (AGL-2495). */\n skippedLocked: number\n}\n\n/**\n * The abandoned-checkout pass itself, separated from its HTTP door (AGL-2227).\n *\n * It was only ever reachable through `processAbandonedHandler`, which is\n * `x-cron-secret`-gated — and **nothing has ever POSTed to it**. It is not in\n * `.github/workflows/scheduled-crons.yml`, there is no `crons` key in\n * `vercel.json`, and it was not a `registerPluginJob`. `server.ts` described\n * these as \"the scheduler-driven jobs\" in a comment, which is how a dead\n * feature stays invisible: the code asserted the wiring rather than having it.\n *\n * So `abandonedCart` — a Pro-tier entitlement, sold on /pricing, with a\n * console row added by AGL-2081 telling the merchant their template needs it —\n * had never sent one email. Exporting the scan lets `server.ts` register it on\n * the platform job beat (the every-minute `pluginJobsBeat` that AGL-2176\n * proved out) while the HTTP door stays for manual/ops invocation and for the\n * console's \"send now\" control.\n */\nexport async function scanAbandonedCheckouts(\n /**\n * The lockdown gate, injected by whoever drives the pass (AGL-2495). NOT\n * optional: a default would make \"forgot to thread it\" compile, which is\n * the whole failure this parameter exists to make impossible.\n */\n gate: PluginJobHostGate,\n): Promise<AbandonedScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const now = Date.now()\n // Collection-group over every host's checkouts.\n const openCheckouts = await firestore\n .collectionGroup('checkouts')\n .where('status', '==', 'open')\n .limit(200)\n .get()\n let sent = 0\n const entitledHosts = new Map<string, boolean>()\n // White-label brand per host (White-Label Phase 3): resolved once per host\n // alongside the entitlement, from the same org doc, through the one shared\n // resolver — so the recovery email's sender reads as the store's brand.\n const brandingByHost = new Map<string, Aglyn.ResolvedBrandingProfile>()\n /*\n * One identity resolution per SITE, not per message.\n *\n * The same reason the branding map above exists: this sweep runs across\n * every site, and an uncached resolution here would be two Firestore reads\n * per recipient rather than per host. Scoped to the run, so a domain\n * un-verified by the re-check sweep is picked up on the next one.\n */\n const identityByHost = new Map<\n string,\n Awaited<ReturnType<typeof hostSendingIdentity>>\n >()\n /** Each site's public origin, for the unsubscribe link on the reminder. */\n const siteBaseByHost = new Map<string, string>()\n /**\n * Each site's consent group, off the same org read as its entitlement, so\n * the gate honors an unsubscribe from any site the org declared one sender\n * with this one, and the org's word on whether its sites wait for each\n * other's confirmation click.\n */\n const consentGroupByHost = new Map<string, Aglyn.ConsentGroup>()\n // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmailWithTokens | null>()\n let skippedLocked = 0\n for (const docSnapshot of openCheckouts.docs) {\n const data = docSnapshot.data() as any\n // `hosts/{hostId}/checkouts/{id}` — the grandparent is the host.\n const hostId = docSnapshot.ref.parent.parent?.id\n if (!hostId) continue\n // LOCKDOWN (AGL-2495), and it is the FIRST thing in the loop body rather\n // than sitting next to the email below, because the `status: 'expired'`\n // stamp further down is itself a write for this host. Reordered for that\n // reason: the cheap filters used to run first, and one of them wrote.\n //\n // SKIPPED, NOT DROPPED: `continue` leaves the checkout `open` and\n // unstamped, so the reminder is sent — or the row expired — on the first\n // beat after the lift. Nothing here marks a checkout done, so declining\n // to act IS leaving it for later.\n if (await gate.isLocked(hostId)) {\n skippedLocked += 1\n continue\n }\n const createdAtMs = Number(data.createdAtMs ?? 0)\n // Expiry comes BEFORE the email and reminded skips. This pass reads 200\n // open checkouts, and a checkout that never had an address, or was\n // already reminded, used to be skipped while still `open` — forever — so\n // enough of them would fill every pass and starve the ones still owed a\n // reminder. Past the give-up age every open checkout closes, whatever\n // else it is.\n if (now - createdAtMs > GIVE_UP_AFTER_MS) {\n await docSnapshot.ref\n .set({ status: 'expired' }, { merge: true })\n .catch(() => undefined)\n continue\n }\n if (!data.email || data.remindedAtMs) continue\n if (now - createdAtMs < REMIND_AFTER_MS) continue\n if (!entitledHosts.has(hostId)) {\n const org = await getOrgForHost(hostId).catch(() => null)\n entitledHosts.set(\n hostId,\n Aglyn.checkEntitlement(org?.org as any, 'abandonedCart'),\n )\n brandingByHost.set(hostId, Aglyn.resolveBrandingProfile(org?.org as any))\n consentGroupByHost.set(\n hostId,\n Aglyn.consentGroupForHost(\n (org?.org as Record<string, unknown> | undefined) ?? null,\n hostId,\n ),\n )\n // The site's own origin, for the unsubscribe link. Resolved once per\n // host for the same reason the branding beside it is: this sweep is a\n // `collectionGroup` over every site's checkouts, and a read per\n // reminder would be a read per shopper.\n const hostSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .get()\n .catch(() => null)\n siteBaseByHost.set(\n hostId,\n Aglyn.hostPublicOrigin({\n cname: hostSnapshot?.get('cname'),\n subdomain: hostSnapshot?.get('subdomain'),\n }) ?? '',\n )\n }\n if (!entitledHosts.get(hostId)) continue\n let loaded = templateCache.get(hostId)\n if (loaded === undefined) {\n loaded = await // The template, or the built-in copy in the site's header and\n // footer, once per site for the whole batch (AGL-3287, AGL-3370).\n loadHostEmailWithTokens(firestore, hostId, 'abandoned-cart')\n templateCache.set(hostId, loaded)\n }\n const designed = loaded\n ? renderLoadedHostEmailWithTokens(\n loaded,\n { 'cart.url': String(data.resumeUrl ?? '') },\n )\n : null\n /*\n * MARKETING, and `'bulk'` priority, which this sweep is entitled to\n * precisely because it is resumable: a checkout left unstamped below is\n * re-scanned on the next beat, so a refusal means \"not this hour\" rather\n * than a reminder nobody ever gets.\n *\n * The gate adds the unsubscribe header pair and a visible link, checks\n * both suppression lists, and counts this against how much mail the\n * shopper has had from this site today.\n */\n const consentGroup =\n consentGroupByHost.get(hostId) ?? Aglyn.soloConsentGroup(hostId)\n const result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? 'You left something in your cart',\n text:\n designed?.text ||\n 'Your cart is still waiting — pick up where you left off:\\n\\n' +\n `${data.resumeUrl ?? ''}\\n\\n` +\n 'Your items are held but not reserved, so they may sell out.',\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: brandingByHost.get(hostId)?.fromName,\n sendingIdentity: await hostSendingIdentity(hostId, identityByHost),\n audience: 'tenant',\n context: 'abandoned cart',\n priority: 'bulk',\n /*\n * A cart reminder is promotional mail — the \"Promotions and offers\"\n * stream, which is also where a campaign lands by default. Declared so\n * a shopper who left that stream stops receiving these; the gate asks\n * the topic only for callers that name one.\n */\n marketing: {\n hostId,\n siteBase: siteBaseByHost.get(hostId) ?? '',\n topicId: Aglyn.EMAIL_TOPIC_MARKETING,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n /*\n * SKIPPED, NOT DROPPED — the lockdown rule above, applied to the two\n * refusals a later beat can pass.\n *\n * `remindedAtMs` is what retires a checkout from this sweep. Stamping it\n * after the hourly ceiling or the frequency cap refused would turn a\n * deferral into a reminder nobody ever gets; NOT stamping it after a\n * suppression or a rejection would re-read the same doomed row on every\n * beat until it crowded out the checkouts that could still be reminded.\n * `isDeferrableSendResult` is the one place that distinction is made.\n */\n if (isDeferrableSendResult(result)) continue\n // Cost meter (AGL-1438). One reminder per abandoned checkout, triggered\n // by that shopper's own action rather than composed as a broadcast, so\n // it counts toward cost without entering the campaign cap. On the\n // DELIVERED message only: a suppressed recipient produced no message, so\n // there is no cost to record.\n if (result.sent) await meterHostEmail(hostId)\n // `recoveryState` beside it, so the console's recovery queue counts this\n // checkout as reminded by query rather than by reading it (AGL-3321).\n const reminded: CheckoutRecoveryState = 'reminded'\n await docSnapshot.ref\n .set({ remindedAtMs: now, recoveryState: reminded }, { merge: true })\n .catch(() => undefined)\n if (result.sent) sent += 1\n }\n return { scanned: openCheckouts.size, sent, skippedLocked }\n}\n\n/**\n * HTTP door for the pass above (AGL-323). Kept `x-cron-secret`-gated so an\n * external scheduler or an operator can still drive it; the job registration\n * in `server.ts` is what makes it actually run.\n */\nexport const processAbandonedHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret) {\n return res.status(501).json({ error: 'Not configured (CRON_SECRET).' })\n }\n if (req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthorized' })\n }\n if (!isEmailConfigured()) {\n return res.status(501).json({ error: 'Email is not configured.' })\n }\n try {\n // The manual door asks the same question the beat does (AGL-2495): a\n // forced pass is still a pass, and `x-cron-secret` is not an argument\n // for mailing a suspended site's shoppers.\n return res.status(200).json(await scanAbandonedCheckouts(siteLockdownJobGate()))\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["Aglyn","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","isDeferrableSendResult","isEmailConfigured","sendEmail","siteLockdownJobGate","loadHostEmailWithTokens","renderLoadedHostEmailWithTokens","CHECKOUT_GIVE_UP_AFTER_MS","GIVE_UP_AFTER_MS","CHECKOUT_REMIND_AFTER_MS","REMIND_AFTER_MS","scanAbandonedCheckouts","gate","firestore","app","now","Date","openCheckouts","collectionGroup","where","limit","get","sent","entitledHosts","Map","brandingByHost","identityByHost","siteBaseByHost","consentGroupByHost","templateCache","skippedLocked","docSnapshot","docs","data","hostId","ref","parent","id","isLocked","createdAtMs","Number","set","status","merge","catch","undefined","email","remindedAtMs","has","org","checkEntitlement","resolveBrandingProfile","consentGroupForHost","hostSnapshot","collection","doc","hostPublicOrigin","cname","subdomain","loaded","designed","String","resumeUrl","consentGroup","soloConsentGroup","result","to","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","siteBase","topicId","EMAIL_TOPIC_MARKETING","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","reminded","recoveryState","scanned","size","processAbandonedHandler","req","res","method","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}
@@ -18,13 +18,13 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import * as CommerceModel from "../model/index.js";
19
19
  import { firebaseAdmin, getOrgForHost, hostSendingIdentity, meterHostEmail } from "@aglyn/tenant-data-admin";
20
20
  import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
21
- import { isDeferrableSendResult, isEmailConfigured, loadHostEmail, renderLoadedHostEmail, sendEmail } from "@aglyn/shared-util-email";
22
- import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-referenced-components";
21
+ import { isDeferrableSendResult, isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
23
22
  // The leaf, not the barrel: the console's `x-cron-secret` door asks the
24
23
  // site's lockdown directly (AGL-3356) — core's registry has no resolver in
25
24
  // the console process — and a spec that substitutes the barrel must still
26
25
  // reach the real verdict.
27
26
  import { siteLockdownJobGate } from "@aglyn/tenant-data-admin/server/tenant-write-lockdown";
27
+ import { loadHostEmailWithTokens, renderLoadedHostEmailWithTokens } from "@aglyn/tenant-data-admin/server/host-email-tokens";
28
28
  /**
29
29
  * The back-in-stock pass itself, separated from its HTTP door (AGL-2227).
30
30
  *
@@ -118,11 +118,8 @@ import { siteLockdownJobGate } from "@aglyn/tenant-data-admin/server/tenant-writ
118
118
  let loaded = templateCache.get(hostRef.id);
119
119
  if (loaded === undefined) {
120
120
  var _ref1, _Aglyn_hostPublicOrigin;
121
- loaded = await loadHostEmail(firestore, hostRef.id, 'back-in-stock', {
122
- // The site's header and footer blocks, grafted once per site for the
123
- // whole sweep (AGL-3287).
124
- compose: composeHostComponentNodes
125
- });
121
+ loaded = await // footer, once per site for the whole batch (AGL-3287, AGL-3370).
122
+ loadHostEmailWithTokens(firestore, hostRef.id, 'back-in-stock');
126
123
  templateCache.set(hostRef.id, loaded);
127
124
  const owner = await getOrgForHost(hostRef.id).catch(()=>null);
128
125
  brandingByHost.set(hostRef.id, Aglyn.resolveBrandingProfile(owner == null ? void 0 : owner.org));
@@ -137,10 +134,10 @@ import { siteLockdownJobGate } from "@aglyn/tenant-data-admin/server/tenant-writ
137
134
  subdomain: hostSnapshot == null ? void 0 : hostSnapshot.get('subdomain')
138
135
  })) != null ? _Aglyn_hostPublicOrigin : '');
139
136
  }
140
- const designed = loaded ? renderLoadedHostEmail(loaded, {
137
+ const designed = loaded ? renderLoadedHostEmailWithTokens(loaded, {
141
138
  'product.name': String((_product_name = product.name) != null ? _product_name : ''),
142
139
  'product.url': productUrl
143
- }, Aglyn.sanitizeAuthorHtml) : null;
140
+ }) : null;
144
141
  /*
145
142
  * MARKETING, and `'bulk'` priority, which this sweep is entitled to
146
143
  * because it is resumable: an alert left unstamped is re-scanned by the
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/process-restock.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport {\n isDeferrableSendResult,\n isEmailConfigured,\n loadHostEmail,\n renderLoadedHostEmail,\n sendEmail,\n type LoadedHostEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n} from '@aglyn/aglyn/server'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\n// The leaf, not the barrel: the console's `x-cron-secret` door asks the\n// site's lockdown directly (AGL-3356) — core's registry has no resolver in\n// the console process — and a spec that substitutes the barrel must still\n// reach the real verdict.\nimport { siteLockdownJobGate } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\n\nexport interface RestockScanResult {\n scanned: number\n sent: number\n /** Alerts left unstamped because their site is locked (AGL-2495). */\n skippedLocked: number\n}\n\n/**\n * The back-in-stock pass itself, separated from its HTTP door (AGL-2227).\n *\n * Same finding as `process-abandoned.ts` beside it: `x-cron-secret`-gated and\n * **never invoked by anything**. `commerce/notify-restock` accepts the\n * shopper's \"tell me when it's back\" from the storefront and writes\n * `hosts/{hostId}/restockAlerts`; this is the only thing that would ever mail\n * them. Shoppers have been entering an address into a queue with no drain.\n *\n * Exported so `server.ts` can put it on the platform job beat.\n */\nexport async function scanRestockAlerts(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n): Promise<RestockScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const alerts = await firestore\n .collectionGroup('restockAlerts')\n .where('notifiedAtMs', '==', null)\n .limit(200)\n .get()\n let sent = 0\n const productCache = new Map<string, CommerceModel.HostProduct | null>()\n // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmail | null>()\n // White-label brand per host (White-Label Phase 3): resolved once per host\n // from the owning org doc through the one shared resolver.\n const brandingByHost = new Map<string, Aglyn.ResolvedBrandingProfile>()\n /*\n * One identity resolution per SITE, not per message.\n *\n * The same reason the branding map above exists: this sweep runs across\n * every site, and an uncached resolution here would be two Firestore reads\n * per recipient rather than per host. Scoped to the run, so a domain\n * un-verified by the re-check sweep is picked up on the next one.\n */\n const identityByHost = new Map<\n string,\n Awaited<ReturnType<typeof hostSendingIdentity>>\n >()\n /** Each site's public origin, for the unsubscribe link on the alert. */\n const siteBaseByHost = new Map<string, string>()\n /**\n * Each site's consent group, off the same org read as its branding, so the\n * gate honors an unsubscribe from any site the org declared one sender\n * with this one, and the org's word on whether its sites wait for each\n * other's confirmation click.\n */\n const consentGroupByHost = new Map<string, Aglyn.ConsentGroup>()\n let skippedLocked = 0\n for (const docSnapshot of alerts.docs) {\n const hostRef = docSnapshot.ref.parent.parent\n if (!hostRef) continue\n // LOCKDOWN (AGL-2495), before anything in this body writes. The poisoned-\n // row retirement below stamps `notifiedAtMs`, which is a write, and the\n // email is a message sent in a suspended merchant's name — both wait.\n //\n // SKIPPED, NOT DROPPED: an alert that is not stamped is re-scanned on the\n // next beat, which is exactly the behaviour the `notifiedAtMs == null`\n // query already relies on.\n if (await gate.isLocked(hostRef.id)) {\n skippedLocked += 1\n continue\n }\n const data = docSnapshot.data() as any\n // AGL-1774: `productId` is a STORED FIELD written by an unauthenticated\n // POST (`notify-restock.ts`), and this line is where it becomes a path\n // component. `.doc()` appends a slash-separated path and throws\n // SYNCHRONOUSLY on an even component count — a throw that landed here,\n // outside the loop body's own error handling, so it aborted the entire\n // run. The scan is a `collectionGroup`, so that run is platform-wide;\n // and because the offending alert was never stamped `notifiedAtMs` it\n // returned on every subsequent run, taking every alert ordered after it\n // down too. One anonymous request stopped back-in-stock email for every\n // merchant, permanently.\n //\n // `notify-restock.ts` now refuses such a value at the door, but this\n // guard is not redundant with that one: rows written BEFORE the fix are\n // already in the database, and a create-time check does nothing about\n // them. Treated as \"no product\" rather than as an error, which is the\n // correct refusal by AGL-1760's test — there is no product, so there is\n // no email owed and no work to discard — and it stamps the alert below\n // so a poisoned row is retired instead of re-scanned forever.\n const productId = isDocumentId(data.productId) ? data.productId : ''\n const cacheKey = `${hostRef.id}:${productId}`\n if (productId && !productCache.has(cacheKey)) {\n const productSnapshot = await hostRef\n .collection('products')\n .doc(productId)\n .get()\n productCache.set(\n cacheKey,\n productSnapshot.exists\n ? CommerceModel.liftLegacyProduct(productSnapshot.data() as any)\n : null,\n )\n }\n const product = productId ? productCache.get(cacheKey) : null\n if (!product) {\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now(), skipped: true }, { merge: true })\n .catch(() => undefined)\n continue\n }\n const total = CommerceModel.productInventory(product)\n if (total != null && total <= 0) continue\n const productUrl = `/products/${product.slug}`\n let loaded = templateCache.get(hostRef.id)\n if (loaded === undefined) {\n loaded = await loadHostEmail(firestore, hostRef.id, 'back-in-stock', {\n // The site's header and footer blocks, grafted once per site for the\n // whole sweep (AGL-3287).\n compose: composeHostComponentNodes,\n })\n templateCache.set(hostRef.id, loaded)\n const owner = await getOrgForHost(hostRef.id).catch(() => null)\n brandingByHost.set(\n hostRef.id,\n Aglyn.resolveBrandingProfile(owner?.org as never),\n )\n consentGroupByHost.set(\n hostRef.id,\n Aglyn.consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostRef.id,\n ),\n )\n // The site's own origin, for the unsubscribe link. Resolved once per\n // host beside the branding, because this sweep is a `collectionGroup`\n // over every site's alerts and a read per alert would be a read per\n // shopper.\n const hostSnapshot = await hostRef.get().catch(() => null)\n siteBaseByHost.set(\n hostRef.id,\n Aglyn.hostPublicOrigin({\n cname: hostSnapshot?.get('cname'),\n subdomain: hostSnapshot?.get('subdomain'),\n }) ?? '',\n )\n }\n const designed = loaded\n ? renderLoadedHostEmail(\n loaded,\n {\n 'product.name': String(product.name ?? ''),\n 'product.url': productUrl,\n },\n Aglyn.sanitizeAuthorHtml,\n )\n : null\n /*\n * MARKETING, and `'bulk'` priority, which this sweep is entitled to\n * because it is resumable: an alert left unstamped is re-scanned by the\n * `notifiedAtMs == null` query on the next beat, so a refusal means \"not\n * this hour\" rather than a shopper who asked to be told and never was.\n *\n * The gate adds the unsubscribe header pair and a visible link, checks\n * both suppression lists, and counts this against how much mail the\n * shopper has had from this site today.\n */\n const consentGroup =\n consentGroupByHost.get(hostRef.id) ?? Aglyn.soloConsentGroup(hostRef.id)\n const result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? `Back in stock: ${product.name}`,\n text:\n designed?.text ||\n `${product.name} is available again — grab it before it sells ` +\n `out:\\n\\n${productUrl}`,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: brandingByHost.get(hostRef.id)?.fromName,\n sendingIdentity: await hostSendingIdentity(hostRef.id, identityByHost),\n audience: 'tenant',\n context: 'restock alert',\n priority: 'bulk',\n /*\n * A restock alert IS the \"Product updates\" stream — the catalog entry\n * names restocks in its own description. Declared so a shopper who\n * unticked that stream on the preference page is not mailed it: the\n * gate asks the topic only for callers that name one, so a sender that\n * left this off would keep mailing them.\n */\n marketing: {\n hostId: hostRef.id,\n siteBase: siteBaseByHost.get(hostRef.id) ?? '',\n topicId: Aglyn.EMAIL_TOPIC_PRODUCT_UPDATES,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n /*\n * SKIPPED, NOT DROPPED — the lockdown rule above, applied to the two\n * refusals a later beat can pass.\n *\n * `notifiedAtMs` is what retires an alert from this sweep. Stamping it\n * after the hourly ceiling or the frequency cap refused would drop a\n * request from a shopper who asked to be told; NOT stamping it after a\n * suppression or a rejection would re-read the same doomed row forever,\n * inside a window of two hundred, starving every alert behind it.\n */\n if (isDeferrableSendResult(result)) continue\n // Cost meter (AGL-1438). One alert per shopper who asked to be told,\n // like the abandoned-checkout reminder beside it, and on the DELIVERED\n // message only — a suppressed recipient produced no message and no cost.\n if (result.sent) await meterHostEmail(hostRef.id)\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now() }, { merge: true })\n .catch(() => undefined)\n if (result.sent) sent += 1\n }\n return { scanned: alerts.size, sent, skippedLocked }\n}\n\n/**\n * HTTP door for the pass above (AGL-326), kept for manual/ops invocation and\n * for the console's \"send now\" control.\n */\nexport const processRestockHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret || req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthorized' })\n }\n if (!isEmailConfigured()) {\n return res.status(501).json({ error: 'Email is not configured.' })\n }\n try {\n // The manual door asks the same question the beat does (AGL-2495).\n return res.status(200).json(await scanRestockAlerts(siteLockdownJobGate()))\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["Aglyn","CommerceModel","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","isDocumentId","isDeferrableSendResult","isEmailConfigured","loadHostEmail","renderLoadedHostEmail","sendEmail","composeHostComponentNodes","siteLockdownJobGate","scanRestockAlerts","gate","firestore","app","alerts","collectionGroup","where","limit","get","sent","productCache","Map","templateCache","brandingByHost","identityByHost","siteBaseByHost","consentGroupByHost","skippedLocked","docSnapshot","docs","product","hostRef","ref","parent","isLocked","id","data","productId","cacheKey","has","productSnapshot","collection","doc","set","exists","liftLegacyProduct","notifiedAtMs","Date","now","skipped","merge","catch","undefined","total","productInventory","productUrl","slug","loaded","compose","owner","resolveBrandingProfile","org","consentGroupForHost","hostSnapshot","hostPublicOrigin","cname","subdomain","designed","String","name","sanitizeAuthorHtml","consentGroup","soloConsentGroup","result","to","email","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","hostId","siteBase","topicId","EMAIL_TOPIC_PRODUCT_UPDATES","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","scanned","size","processRestockHandler","req","res","method","status","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/process-restock.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport {\n isDeferrableSendResult,\n isEmailConfigured,\n sendEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n} from '@aglyn/aglyn/server'\n// The leaf, not the barrel: the console's `x-cron-secret` door asks the\n// site's lockdown directly (AGL-3356) — core's registry has no resolver in\n// the console process — and a spec that substitutes the barrel must still\n// reach the real verdict.\nimport { siteLockdownJobGate } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\nimport {\n loadHostEmailWithTokens,\n renderLoadedHostEmailWithTokens,\n type LoadedHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin/server/host-email-tokens'\n\nexport interface RestockScanResult {\n scanned: number\n sent: number\n /** Alerts left unstamped because their site is locked (AGL-2495). */\n skippedLocked: number\n}\n\n/**\n * The back-in-stock pass itself, separated from its HTTP door (AGL-2227).\n *\n * Same finding as `process-abandoned.ts` beside it: `x-cron-secret`-gated and\n * **never invoked by anything**. `commerce/notify-restock` accepts the\n * shopper's \"tell me when it's back\" from the storefront and writes\n * `hosts/{hostId}/restockAlerts`; this is the only thing that would ever mail\n * them. Shoppers have been entering an address into a queue with no drain.\n *\n * Exported so `server.ts` can put it on the platform job beat.\n */\nexport async function scanRestockAlerts(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n): Promise<RestockScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const alerts = await firestore\n .collectionGroup('restockAlerts')\n .where('notifiedAtMs', '==', null)\n .limit(200)\n .get()\n let sent = 0\n const productCache = new Map<string, CommerceModel.HostProduct | null>()\n // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmailWithTokens | null>()\n // White-label brand per host (White-Label Phase 3): resolved once per host\n // from the owning org doc through the one shared resolver.\n const brandingByHost = new Map<string, Aglyn.ResolvedBrandingProfile>()\n /*\n * One identity resolution per SITE, not per message.\n *\n * The same reason the branding map above exists: this sweep runs across\n * every site, and an uncached resolution here would be two Firestore reads\n * per recipient rather than per host. Scoped to the run, so a domain\n * un-verified by the re-check sweep is picked up on the next one.\n */\n const identityByHost = new Map<\n string,\n Awaited<ReturnType<typeof hostSendingIdentity>>\n >()\n /** Each site's public origin, for the unsubscribe link on the alert. */\n const siteBaseByHost = new Map<string, string>()\n /**\n * Each site's consent group, off the same org read as its branding, so the\n * gate honors an unsubscribe from any site the org declared one sender\n * with this one, and the org's word on whether its sites wait for each\n * other's confirmation click.\n */\n const consentGroupByHost = new Map<string, Aglyn.ConsentGroup>()\n let skippedLocked = 0\n for (const docSnapshot of alerts.docs) {\n const hostRef = docSnapshot.ref.parent.parent\n if (!hostRef) continue\n // LOCKDOWN (AGL-2495), before anything in this body writes. The poisoned-\n // row retirement below stamps `notifiedAtMs`, which is a write, and the\n // email is a message sent in a suspended merchant's name — both wait.\n //\n // SKIPPED, NOT DROPPED: an alert that is not stamped is re-scanned on the\n // next beat, which is exactly the behaviour the `notifiedAtMs == null`\n // query already relies on.\n if (await gate.isLocked(hostRef.id)) {\n skippedLocked += 1\n continue\n }\n const data = docSnapshot.data() as any\n // AGL-1774: `productId` is a STORED FIELD written by an unauthenticated\n // POST (`notify-restock.ts`), and this line is where it becomes a path\n // component. `.doc()` appends a slash-separated path and throws\n // SYNCHRONOUSLY on an even component count — a throw that landed here,\n // outside the loop body's own error handling, so it aborted the entire\n // run. The scan is a `collectionGroup`, so that run is platform-wide;\n // and because the offending alert was never stamped `notifiedAtMs` it\n // returned on every subsequent run, taking every alert ordered after it\n // down too. One anonymous request stopped back-in-stock email for every\n // merchant, permanently.\n //\n // `notify-restock.ts` now refuses such a value at the door, but this\n // guard is not redundant with that one: rows written BEFORE the fix are\n // already in the database, and a create-time check does nothing about\n // them. Treated as \"no product\" rather than as an error, which is the\n // correct refusal by AGL-1760's test — there is no product, so there is\n // no email owed and no work to discard — and it stamps the alert below\n // so a poisoned row is retired instead of re-scanned forever.\n const productId = isDocumentId(data.productId) ? data.productId : ''\n const cacheKey = `${hostRef.id}:${productId}`\n if (productId && !productCache.has(cacheKey)) {\n const productSnapshot = await hostRef\n .collection('products')\n .doc(productId)\n .get()\n productCache.set(\n cacheKey,\n productSnapshot.exists\n ? CommerceModel.liftLegacyProduct(productSnapshot.data() as any)\n : null,\n )\n }\n const product = productId ? productCache.get(cacheKey) : null\n if (!product) {\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now(), skipped: true }, { merge: true })\n .catch(() => undefined)\n continue\n }\n const total = CommerceModel.productInventory(product)\n if (total != null && total <= 0) continue\n const productUrl = `/products/${product.slug}`\n let loaded = templateCache.get(hostRef.id)\n if (loaded === undefined) {\n loaded = await // The template, or the built-in copy in the site's header and\n // footer, once per site for the whole batch (AGL-3287, AGL-3370).\n loadHostEmailWithTokens(firestore, hostRef.id, 'back-in-stock')\n templateCache.set(hostRef.id, loaded)\n const owner = await getOrgForHost(hostRef.id).catch(() => null)\n brandingByHost.set(\n hostRef.id,\n Aglyn.resolveBrandingProfile(owner?.org as never),\n )\n consentGroupByHost.set(\n hostRef.id,\n Aglyn.consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostRef.id,\n ),\n )\n // The site's own origin, for the unsubscribe link. Resolved once per\n // host beside the branding, because this sweep is a `collectionGroup`\n // over every site's alerts and a read per alert would be a read per\n // shopper.\n const hostSnapshot = await hostRef.get().catch(() => null)\n siteBaseByHost.set(\n hostRef.id,\n Aglyn.hostPublicOrigin({\n cname: hostSnapshot?.get('cname'),\n subdomain: hostSnapshot?.get('subdomain'),\n }) ?? '',\n )\n }\n const designed = loaded\n ? renderLoadedHostEmailWithTokens(\n loaded,\n {\n 'product.name': String(product.name ?? ''),\n 'product.url': productUrl,\n },\n )\n : null\n /*\n * MARKETING, and `'bulk'` priority, which this sweep is entitled to\n * because it is resumable: an alert left unstamped is re-scanned by the\n * `notifiedAtMs == null` query on the next beat, so a refusal means \"not\n * this hour\" rather than a shopper who asked to be told and never was.\n *\n * The gate adds the unsubscribe header pair and a visible link, checks\n * both suppression lists, and counts this against how much mail the\n * shopper has had from this site today.\n */\n const consentGroup =\n consentGroupByHost.get(hostRef.id) ?? Aglyn.soloConsentGroup(hostRef.id)\n const result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? `Back in stock: ${product.name}`,\n text:\n designed?.text ||\n `${product.name} is available again — grab it before it sells ` +\n `out:\\n\\n${productUrl}`,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: brandingByHost.get(hostRef.id)?.fromName,\n sendingIdentity: await hostSendingIdentity(hostRef.id, identityByHost),\n audience: 'tenant',\n context: 'restock alert',\n priority: 'bulk',\n /*\n * A restock alert IS the \"Product updates\" stream — the catalog entry\n * names restocks in its own description. Declared so a shopper who\n * unticked that stream on the preference page is not mailed it: the\n * gate asks the topic only for callers that name one, so a sender that\n * left this off would keep mailing them.\n */\n marketing: {\n hostId: hostRef.id,\n siteBase: siteBaseByHost.get(hostRef.id) ?? '',\n topicId: Aglyn.EMAIL_TOPIC_PRODUCT_UPDATES,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n /*\n * SKIPPED, NOT DROPPED — the lockdown rule above, applied to the two\n * refusals a later beat can pass.\n *\n * `notifiedAtMs` is what retires an alert from this sweep. Stamping it\n * after the hourly ceiling or the frequency cap refused would drop a\n * request from a shopper who asked to be told; NOT stamping it after a\n * suppression or a rejection would re-read the same doomed row forever,\n * inside a window of two hundred, starving every alert behind it.\n */\n if (isDeferrableSendResult(result)) continue\n // Cost meter (AGL-1438). One alert per shopper who asked to be told,\n // like the abandoned-checkout reminder beside it, and on the DELIVERED\n // message only — a suppressed recipient produced no message and no cost.\n if (result.sent) await meterHostEmail(hostRef.id)\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now() }, { merge: true })\n .catch(() => undefined)\n if (result.sent) sent += 1\n }\n return { scanned: alerts.size, sent, skippedLocked }\n}\n\n/**\n * HTTP door for the pass above (AGL-326), kept for manual/ops invocation and\n * for the console's \"send now\" control.\n */\nexport const processRestockHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret || req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthorized' })\n }\n if (!isEmailConfigured()) {\n return res.status(501).json({ error: 'Email is not configured.' })\n }\n try {\n // The manual door asks the same question the beat does (AGL-2495).\n return res.status(200).json(await scanRestockAlerts(siteLockdownJobGate()))\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["Aglyn","CommerceModel","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","isDocumentId","isDeferrableSendResult","isEmailConfigured","sendEmail","siteLockdownJobGate","loadHostEmailWithTokens","renderLoadedHostEmailWithTokens","scanRestockAlerts","gate","firestore","app","alerts","collectionGroup","where","limit","get","sent","productCache","Map","templateCache","brandingByHost","identityByHost","siteBaseByHost","consentGroupByHost","skippedLocked","docSnapshot","docs","product","hostRef","ref","parent","isLocked","id","data","productId","cacheKey","has","productSnapshot","collection","doc","set","exists","liftLegacyProduct","notifiedAtMs","Date","now","skipped","merge","catch","undefined","total","productInventory","productUrl","slug","loaded","owner","resolveBrandingProfile","org","consentGroupForHost","hostSnapshot","hostPublicOrigin","cname","subdomain","designed","String","name","consentGroup","soloConsentGroup","result","to","email","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","hostId","siteBase","topicId","EMAIL_TOPIC_PRODUCT_UPDATES","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","scanned","size","processRestockHandler","req","res","method","status","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}
@@ -95,6 +95,17 @@ import { readProductReviews } from "./reviews.js";
95
95
  'product.price': minPrice === maxPrice ? `$${minPrice}` : `From $${minPrice}`,
96
96
  'product.image': (_ref = (_ref1 = (_product_mediaUrls = product.mediaUrls) == null ? void 0 : _product_mediaUrls[0]) != null ? _ref1 : product.imageUrl) != null ? _ref : '',
97
97
  'product.slug': product.slug
98
+ },
99
+ // Named as the product template it is if the page review holds it
100
+ // (AGL-3374); without it the route has no page.
101
+ page: {
102
+ template: {
103
+ role: 'entry',
104
+ route: '/products/:slug',
105
+ collectionName: 'Products',
106
+ entryPath: `/products/${product.slug}`,
107
+ fallback: 'not-found'
108
+ }
98
109
  }
99
110
  });
100
111
  if (templateNodes) {
@@ -186,6 +197,16 @@ import { readProductReviews } from "./reviews.js";
186
197
  'collection.description': (_shopCollection_description = shopCollection.description) != null ? _shopCollection_description : '',
187
198
  'collection.image': (_shopCollection_imageUrl = shopCollection.imageUrl) != null ? _shopCollection_imageUrl : '',
188
199
  'collection.slug': shopCollection.slug
200
+ },
201
+ // As on the PDP above (AGL-3374).
202
+ page: {
203
+ template: {
204
+ role: 'entry',
205
+ route: '/collections/:slug',
206
+ collectionName: 'Store collections',
207
+ entryPath: `/collections/${shopCollection.slug}`,
208
+ fallback: 'not-found'
209
+ }
189
210
  }
190
211
  });
191
212
  if (templateNodes) {