@aglyn/plugins-email 1.0.0-beta.143

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.
Files changed (144) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +58 -0
  4. package/src/index.d.ts +35 -0
  5. package/src/index.js +35 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/campaign-design-create-widget.d.ts +21 -0
  8. package/src/lib/components/campaign-design-create-widget.js +61 -0
  9. package/src/lib/components/campaign-design-create-widget.js.map +1 -0
  10. package/src/lib/components/campaign-sender-editor-widget.d.ts +18 -0
  11. package/src/lib/components/campaign-sender-editor-widget.js +39 -0
  12. package/src/lib/components/campaign-sender-editor-widget.js.map +1 -0
  13. package/src/lib/components/campaign-topic-options-widget.d.ts +19 -0
  14. package/src/lib/components/campaign-topic-options-widget.js +48 -0
  15. package/src/lib/components/campaign-topic-options-widget.js.map +1 -0
  16. package/src/lib/components/campaign-topic-select.d.ts +32 -0
  17. package/src/lib/components/campaign-topic-select.js +87 -0
  18. package/src/lib/components/campaign-topic-select.js.map +1 -0
  19. package/src/lib/components/dynamic-list-rule-fields.d.ts +173 -0
  20. package/src/lib/components/dynamic-list-rule-fields.js +1473 -0
  21. package/src/lib/components/dynamic-list-rule-fields.js.map +1 -0
  22. package/src/lib/components/email-blocks.d.ts +111 -0
  23. package/src/lib/components/email-blocks.js +875 -0
  24. package/src/lib/components/email-blocks.js.map +1 -0
  25. package/src/lib/components/email-design-preview.d.ts +62 -0
  26. package/src/lib/components/email-design-preview.js +174 -0
  27. package/src/lib/components/email-design-preview.js.map +1 -0
  28. package/src/lib/components/email-screens-card.d.ts +42 -0
  29. package/src/lib/components/email-screens-card.js +277 -0
  30. package/src/lib/components/email-screens-card.js.map +1 -0
  31. package/src/lib/components/email-template-detail.d.ts +48 -0
  32. package/src/lib/components/email-template-detail.js +681 -0
  33. package/src/lib/components/email-template-detail.js.map +1 -0
  34. package/src/lib/components/email-topic-detail.d.ts +32 -0
  35. package/src/lib/components/email-topic-detail.js +293 -0
  36. package/src/lib/components/email-topic-detail.js.map +1 -0
  37. package/src/lib/components/email-topics-card.d.ts +46 -0
  38. package/src/lib/components/email-topics-card.js +327 -0
  39. package/src/lib/components/email-topics-card.js.map +1 -0
  40. package/src/lib/components/email-zones.d.ts +28 -0
  41. package/src/lib/components/email-zones.js +20 -0
  42. package/src/lib/components/email-zones.js.map +1 -0
  43. package/src/lib/components/emails-console-page.d.ts +32 -0
  44. package/src/lib/components/emails-console-page.js +229 -0
  45. package/src/lib/components/emails-console-page.js.map +1 -0
  46. package/src/lib/components/emails-console-sections.d.ts +36 -0
  47. package/src/lib/components/emails-console-sections.js +108 -0
  48. package/src/lib/components/emails-console-sections.js.map +1 -0
  49. package/src/lib/components/list-detail-card.d.ts +47 -0
  50. package/src/lib/components/list-detail-card.js +273 -0
  51. package/src/lib/components/list-detail-card.js.map +1 -0
  52. package/src/lib/components/list-edit-card.d.ts +11 -0
  53. package/src/lib/components/list-edit-card.js +287 -0
  54. package/src/lib/components/list-edit-card.js.map +1 -0
  55. package/src/lib/components/list-import-drawer.d.ts +22 -0
  56. package/src/lib/components/list-import-drawer.js +662 -0
  57. package/src/lib/components/list-import-drawer.js.map +1 -0
  58. package/src/lib/components/list-members-panel.d.ts +94 -0
  59. package/src/lib/components/list-members-panel.js +686 -0
  60. package/src/lib/components/list-members-panel.js.map +1 -0
  61. package/src/lib/components/lists-card.d.ts +28 -0
  62. package/src/lib/components/lists-card.js +377 -0
  63. package/src/lib/components/lists-card.js.map +1 -0
  64. package/src/lib/components/sending-domain-detail.d.ts +26 -0
  65. package/src/lib/components/sending-domain-detail.js +496 -0
  66. package/src/lib/components/sending-domain-detail.js.map +1 -0
  67. package/src/lib/components/sending-domains-card.d.ts +33 -0
  68. package/src/lib/components/sending-domains-card.js +962 -0
  69. package/src/lib/components/sending-domains-card.js.map +1 -0
  70. package/src/lib/components/sending-sender-drawer.d.ts +94 -0
  71. package/src/lib/components/sending-sender-drawer.js +543 -0
  72. package/src/lib/components/sending-sender-drawer.js.map +1 -0
  73. package/src/lib/components/suppressions-card.d.ts +49 -0
  74. package/src/lib/components/suppressions-card.js +639 -0
  75. package/src/lib/components/suppressions-card.js.map +1 -0
  76. package/src/lib/components/use-org-email-topics.d.ts +79 -0
  77. package/src/lib/components/use-org-email-topics.js +111 -0
  78. package/src/lib/components/use-org-email-topics.js.map +1 -0
  79. package/src/lib/constants/bundle-common.d.ts +18 -0
  80. package/src/lib/constants/bundle-common.js +18 -0
  81. package/src/lib/constants/bundle-common.js.map +1 -0
  82. package/src/lib/hooks/use-org-company-options.d.ts +20 -0
  83. package/src/lib/hooks/use-org-company-options.js +138 -0
  84. package/src/lib/hooks/use-org-company-options.js.map +1 -0
  85. package/src/lib/hooks/use-org-contact-fields.d.ts +40 -0
  86. package/src/lib/hooks/use-org-contact-fields.js +91 -0
  87. package/src/lib/hooks/use-org-contact-fields.js.map +1 -0
  88. package/src/lib/hooks/use-org-contact-segments.d.ts +16 -0
  89. package/src/lib/hooks/use-org-contact-segments.js +55 -0
  90. package/src/lib/hooks/use-org-contact-segments.js.map +1 -0
  91. package/src/lib/hooks/use-org-crm-views.d.ts +8 -0
  92. package/src/lib/hooks/use-org-crm-views.js +74 -0
  93. package/src/lib/hooks/use-org-crm-views.js.map +1 -0
  94. package/src/lib/hooks/use-org-lists.d.ts +8 -0
  95. package/src/lib/hooks/use-org-lists.js +47 -0
  96. package/src/lib/hooks/use-org-lists.js.map +1 -0
  97. package/src/lib/model/email-design-document.d.ts +52 -0
  98. package/src/lib/model/email-design-document.js +62 -0
  99. package/src/lib/model/email-design-document.js.map +1 -0
  100. package/src/lib/model/index.d.ts +64 -0
  101. package/src/lib/model/index.js +71 -0
  102. package/src/lib/model/index.js.map +1 -0
  103. package/src/lib/model/sending-domain-status.d.ts +99 -0
  104. package/src/lib/model/sending-domain-status.js +196 -0
  105. package/src/lib/model/sending-domain-status.js.map +1 -0
  106. package/src/lib/model/template-provenance.d.ts +113 -0
  107. package/src/lib/model/template-provenance.js +107 -0
  108. package/src/lib/model/template-provenance.js.map +1 -0
  109. package/src/lib/model/template-report.d.ts +158 -0
  110. package/src/lib/model/template-report.js +249 -0
  111. package/src/lib/model/template-report.js.map +1 -0
  112. package/src/lib/plugin.d.ts +27 -0
  113. package/src/lib/plugin.js +163 -0
  114. package/src/lib/plugin.js.map +1 -0
  115. package/src/lib/server-console.d.ts +116 -0
  116. package/src/lib/server-console.js +422 -0
  117. package/src/lib/server-console.js.map +1 -0
  118. package/src/lib/server-email-drafts.d.ts +104 -0
  119. package/src/lib/server-email-drafts.js +381 -0
  120. package/src/lib/server-email-drafts.js.map +1 -0
  121. package/src/lib/server-list-gate.d.ts +183 -0
  122. package/src/lib/server-list-gate.js +365 -0
  123. package/src/lib/server-list-gate.js.map +1 -0
  124. package/src/lib/server-list-import.d.ts +199 -0
  125. package/src/lib/server-list-import.js +632 -0
  126. package/src/lib/server-list-import.js.map +1 -0
  127. package/src/lib/server-suppressions.d.ts +135 -0
  128. package/src/lib/server-suppressions.js +295 -0
  129. package/src/lib/server-suppressions.js.map +1 -0
  130. package/src/lib/server.d.ts +19 -0
  131. package/src/lib/server.js +834 -0
  132. package/src/lib/server.js.map +1 -0
  133. package/src/lib/site.d.ts +26 -0
  134. package/src/lib/site.js +81 -0
  135. package/src/lib/site.js.map +1 -0
  136. package/src/lib/unsubscribe-link.d.ts +311 -0
  137. package/src/lib/unsubscribe-link.js +398 -0
  138. package/src/lib/unsubscribe-link.js.map +1 -0
  139. package/src/lib/utils/create-email-screen.d.ts +59 -0
  140. package/src/lib/utils/create-email-screen.js +59 -0
  141. package/src/lib/utils/create-email-screen.js.map +1 -0
  142. package/src/lib/utils/generate-preset-id.d.ts +19 -0
  143. package/src/lib/utils/generate-preset-id.js +25 -0
  144. package/src/lib/utils/generate-preset-id.js.map +1 -0
@@ -0,0 +1,834 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { registerPluginApiRoute } from "@aglyn/aglyn/server";
18
+ /*
19
+ * The MODULE, not the barrel. `@aglyn/aglyn` re-exports the app-utils index,
20
+ * which reaches `enabled-plugins-context` and therefore React — and this file
21
+ * is loaded by the plugin API route's SERVER graph, where a client-only
22
+ * module is a bundle `app-router-graph.spec.ts` refuses. Every name here is a
23
+ * pure function or a constant that lives in one leaf file.
24
+ */ import { activeEmailTopics, mergeEmailTopics, normalizeEmailTopic, readTopicSubscriptionState, resolveCampaignTopic, EMAIL_TOPICS_COLLECTION, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
25
+ import { confirmTopicSubscription, EMAIL_FREQUENCY_SUBCOLLECTION, firebaseAdmin, resolveOrgIdForHost, setMarketingCadence, UNSUBSCRIBE_SUPPRESSION_REASON } from "@aglyn/tenant-data-admin";
26
+ /*
27
+ * The pure cadence rule from the shared email library, where the SEND path
28
+ * reads it too. The preference page and the gate must agree about what
29
+ * `'weekly'` means down to the coercion of a malformed value, and two copies
30
+ * of that is how a page comes to record a choice the gate does not recognize.
31
+ */ import { normalizeMarketingCadence } from "@aglyn/shared-util-email";
32
+ import { escapeHtml } from "@aglyn/shared-util-tools/escape-html";
33
+ import { FieldValue } from "firebase-admin/firestore";
34
+ import { heading, isCampaignPathId, page, paragraph, PAL, PLATFORM_EMAIL_BRAND, readParams, resolveEmailPageBrand, sendPage, signatureMatches, signedQuery, submitButton, successBadge, suppressionKeyFor } from "./unsubscribe-link.js";
35
+ /**
36
+ * One-click unsubscribe (AGL-161), split into a safe GET and a mutating POST
37
+ * (AGL-2408), with a preference center in front of the human-facing half.
38
+ *
39
+ * ## Why the GET stopped writing
40
+ *
41
+ * This handler used to write the suppression on GET, and the docblock called
42
+ * that a feature: "GET so it works from any mail client; idempotent."
43
+ * Idempotent is not the property that matters. A GET must be SAFE — free of
44
+ * side effects the user did not ask for — and this one was not.
45
+ *
46
+ * Every mail client and security gateway of consequence (Microsoft Defender
47
+ * for Office 365's Safe Links, Google's own scanners, Proofpoint, Mimecast)
48
+ * FETCHES every URL in a message before the recipient ever sees it, to check
49
+ * where it lands. Each of those fetches silently unsubscribed the recipient
50
+ * from that merchant's list. The recipient never clicked anything; the
51
+ * merchant sees their audience shrink and cannot explain it; and until
52
+ * AGL-2410 there was no screen in the product to even discover it, let alone
53
+ * undo it. That is a customer's marketing list being destroyed by a
54
+ * prescanner, on our side of the line.
55
+ *
56
+ * So: GET renders a page carrying a same-URL POST form, and only the POST
57
+ * writes. A prescanner following any of these three links now renders a page
58
+ * and changes nothing. That property is not negotiable and every handler in
59
+ * this file holds it.
60
+ *
61
+ * ## RFC 8058 one-click
62
+ *
63
+ * Gmail's and Yahoo's bulk-sender rules ask for `List-Unsubscribe` PLUS
64
+ * `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, and a client honoring
65
+ * that pair sends a POST to the header URL with `List-Unsubscribe=One-Click`
66
+ * as an `application/x-www-form-urlencoded` body. `unsubscribeHandler`'s POST
67
+ * branch is exactly what that lands on — which is why the two halves had to be
68
+ * fixed together: turning the GET into a confirmation page without accepting
69
+ * POST would have broken unsubscribe outright, and advertising one-click while
70
+ * the only mutating verb was GET would have been the same bug with a header on
71
+ * top.
72
+ *
73
+ * THE PREFERENCE CENTER IS NOT IN THAT PATH, and must never be. The
74
+ * `List-Unsubscribe` header still names `email/unsubscribe`, whose POST acts
75
+ * immediately with no page in between; the preference center is what the
76
+ * FOOTER link in the message body points at, where a human is present to make
77
+ * a choice. Routing the header at a page of checkboxes would be advertising
78
+ * one-click against a surface that cannot honor it — a mailbox provider POSTs
79
+ * that URL with nobody watching, reads a 200, and reports the recipient
80
+ * unsubscribed when nothing was written.
81
+ *
82
+ * The one-click POST carries no `Origin` header (it is sent by the mailbox
83
+ * provider's servers, not a browser), which the dispatcher's same-origin gate
84
+ * deliberately allows; the forms' POSTs are same-origin. Neither needs a CSRF
85
+ * token beyond the HMAC already in the URL: a caller who cannot produce `sig`
86
+ * cannot unsubscribe anyone, and a caller who can is holding the recipient's
87
+ * own mail.
88
+ *
89
+ * ## No `mailto:` variant, and why that is a deliberate hole
90
+ *
91
+ * RFC 8058 also permits a `mailto:` fallback in the header. Adding one now
92
+ * would point recipients at an address nobody reads — `docs/EMAIL_SETUP.md`
93
+ * lists a monitored `hello@aglyn.com` as an unstarted idea — and an
94
+ * unsubscribe request that lands in an unmonitored inbox is worse than no
95
+ * fallback at all, because the recipient believes they have unsubscribed. It
96
+ * needs a mailbox and an inbound route, which is provider setup rather than
97
+ * repo work.
98
+ */ /** Read from both verbs; the secret the link was signed with. */ function linkSecret() {
99
+ return process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.CRON_SECRET || '';
100
+ }
101
+ function openSignedLink(req) {
102
+ const params = readParams(req);
103
+ const secret = linkSecret();
104
+ const refuse = (status)=>({
105
+ refusal: status,
106
+ params,
107
+ key: ''
108
+ });
109
+ if (!params.hostId || !params.email || !params.signature || !secret) {
110
+ return refuse(400);
111
+ }
112
+ if (!signatureMatches(_extends({}, params, {
113
+ secret
114
+ }))) return refuse(403);
115
+ // `personKey` refuses a value that is not an address rather than hashing it,
116
+ // so a signed link naming a malformed address is a bad link and not a
117
+ // suppression document for a person who does not exist.
118
+ const key = suppressionKeyFor(params.email);
119
+ if (!key) return refuse(400);
120
+ return {
121
+ refusal: 0,
122
+ params,
123
+ key
124
+ };
125
+ }
126
+ /**
127
+ * How long the shell will wait for the sending site's identity.
128
+ *
129
+ * A branded page is worth one host read; it is not worth a page that never
130
+ * arrives. These four routes are the recipient's only way to stop the mail, so
131
+ * an unbranded page rendered promptly beats a correct one that hangs behind a
132
+ * slow read — the timeout falls back rather than failing.
133
+ */ const BRAND_READ_TIMEOUT_MS = 1500;
134
+ /**
135
+ * The SENDING SITE's identity for the shell, not ours.
136
+ *
137
+ * One read of `hosts/{hostId}`, and every failure mode lands on the same
138
+ * answer: no host id, a missing document, a read that throws, a read that is
139
+ * slow, or a host that has simply set no brand all resolve to
140
+ * {@link PLATFORM_EMAIL_BRAND}. That is also the self-host answer, so the
141
+ * fallback path is the one an operator runs every day rather than a branch
142
+ * only reached when something is broken.
143
+ */ async function loadHostBrand(hostId) {
144
+ if (!hostId) return PLATFORM_EMAIL_BRAND;
145
+ let timer;
146
+ try {
147
+ const firestore = firebaseAdmin.app().firestore();
148
+ const snapshot = await Promise.race([
149
+ firestore.collection('hosts').doc(hostId).get(),
150
+ new Promise((resolve)=>{
151
+ timer = setTimeout(()=>resolve(null), BRAND_READ_TIMEOUT_MS);
152
+ })
153
+ ]);
154
+ if (!(snapshot == null ? void 0 : snapshot.exists)) return PLATFORM_EMAIL_BRAND;
155
+ // The id LAST: it addresses the `media:` logo reference, and the copy of
156
+ // it stored in the document is the one that can be stale or absent.
157
+ return resolveEmailPageBrand(_extends({}, snapshot.data(), {
158
+ $id: hostId
159
+ }));
160
+ } catch (error) {
161
+ console.error('[email] host brand read failed', error);
162
+ return PLATFORM_EMAIL_BRAND;
163
+ } finally{
164
+ if (timer) clearTimeout(timer);
165
+ }
166
+ }
167
+ const unsubscribeHandler = async (req, res)=>{
168
+ var _req_method;
169
+ const method = String((_req_method = req.method) != null ? _req_method : 'GET').toUpperCase();
170
+ if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {
171
+ res.setHeader('Allow', 'GET, POST');
172
+ return void res.status(405).send('Method not allowed');
173
+ }
174
+ const opened = openSignedLink(req);
175
+ if (opened.refusal) {
176
+ return void res.status(opened.refusal).send('Invalid unsubscribe link');
177
+ }
178
+ const { params, key } = opened;
179
+ const { hostId, email, campaignId, topicId } = params;
180
+ const query = signedQuery(params);
181
+ if (method !== 'POST') {
182
+ // SAFE. A prescanner lands here and nothing is written — the brand read
183
+ // is the only Firestore access on this path, and it is a read.
184
+ const brand = await loadHostBrand(hostId);
185
+ return void sendPage(res, page(heading('Unsubscribe?') + paragraph(`Confirm that <strong style="color:${PAL.ink}">${escapeHtml(email)}</strong> should stop receiving emails from ` + `<strong style="color:${PAL.ink}">${escapeHtml(brand.name)}</strong>.`) + `<form method="post" action="/api/email/unsubscribe?${escapeHtml(query)}">` + submitButton('Unsubscribe', {
186
+ pal: brand.pal
187
+ }) + '</form>' + // The way to a NARROWER choice, offered on the page rather than only
188
+ // in the message footer: a recipient who reached the total
189
+ // unsubscribe from a mail client's own link has never been shown
190
+ // that leaving one stream is possible.
191
+ `<p style="margin:16px 0 0;font-size:13px;line-height:1.5;text-align:center">` + `<a href="/api/email/preferences?${escapeHtml(query)}" ` + `style="color:${brand.pal.link};text-decoration:none">` + 'Choose which emails to stop instead</a></p>', 420, brand));
192
+ }
193
+ try {
194
+ const firestore = firebaseAdmin.app().firestore();
195
+ const [created, brand] = await Promise.all([
196
+ writeSiteSuppression(firestore, hostId, key, {
197
+ email,
198
+ campaignId,
199
+ topicId
200
+ }),
201
+ loadHostBrand(hostId)
202
+ ]);
203
+ /*
204
+ * The campaign's own unsubscribe count.
205
+ *
206
+ * AFTER the suppression and with its failure swallowed, for the reason
207
+ * the delivery webhook orders its writes the same way: the suppression is
208
+ * the write that must happen, and a statistic must never be able to cost
209
+ * one. A lost increment understates an unsubscribe rate; a lost
210
+ * suppression mails somebody who asked us not to.
211
+ *
212
+ * A merge-set would CREATE the campaign — a document holding one `stats`
213
+ * map and nothing else — for an unsubscribe arriving after the merchant
214
+ * deleted it, which is the fault the delivery webhook records against
215
+ * this exact shape. `update()` refuses a missing document, which is the
216
+ * behavior wanted: the count for a campaign nobody can open has no
217
+ * reader.
218
+ */ if (created && isCampaignPathId(campaignId)) {
219
+ await firestore.collection('hosts').doc(hostId).collection('campaigns').doc(campaignId).update({
220
+ 'stats.unsubscribes': FieldValue.increment(1)
221
+ }).catch(()=>undefined);
222
+ }
223
+ return void sendPage(res, page(successBadge(brand.pal) + heading("You're unsubscribed") + paragraph(`You won't receive further emails from ${escapeHtml(brand.name)}.`, 20) + // Same signed params, so the click that just proved this is really
224
+ // this recipient's link doubles as the resubscribe link — no new
225
+ // token, no second email round-trip (AGL-2499).
226
+ `<a href="/api/email/resubscribe?${escapeHtml(query)}" ` + `style="font-size:13px;color:${brand.pal.link};text-decoration:none">` + 'Changed your mind? Resubscribe</a>', 420, brand));
227
+ } catch (error) {
228
+ console.error(error);
229
+ return void res.status(500).send('Unsubscribe failed — please try again');
230
+ }
231
+ };
232
+ /**
233
+ * The whole-site suppression, written the same way from both routes that
234
+ * write one.
235
+ *
236
+ * `reason: 'unsubscribe'` is written explicitly (AGL-2410). The Resend webhook
237
+ * stamps `'bounce'` / `'complaint'`, and until now an unsubscribe was the only
238
+ * entry with no reason at all — so a reader had to infer one from an absent
239
+ * field, which is a rule that holds only while nothing else ever forgets to
240
+ * write it.
241
+ *
242
+ * `createdAt` is written only when the document is new, matching
243
+ * `email-events.ts`: a bounce arriving after an unsubscribe must not restamp
244
+ * the date the person actually unsubscribed, and neither must a second click
245
+ * on the same link.
246
+ *
247
+ * @returns WHETHER THIS CLICK CREATED THE SUPPRESSION, decided inside the
248
+ * transaction and used outside it. It is the idempotency the campaign
249
+ * counter needs, and it comes for free because the transaction
250
+ * already reads the document to decide whether to stamp `createdAt`.
251
+ * A second click on the same link — and there will be second clicks,
252
+ * from a person pressing the button twice and from a client
253
+ * re-POSTing a one-click header — finds the entry present and
254
+ * contributes nothing, so `stats.unsubscribes` counts PEOPLE who left
255
+ * rather than button presses.
256
+ */ async function writeSiteSuppression(firestore, hostId, key, fields) {
257
+ const ref = firestore.collection('hosts').doc(hostId).collection('suppressions').doc(key);
258
+ /*
259
+ * Assigned rather than or-ed inside the body because a Firestore
260
+ * transaction may retry, and the reading that counts is the one whose write
261
+ * committed.
262
+ */ let created = false;
263
+ await firestore.runTransaction(async (transaction)=>{
264
+ const existing = await transaction.get(ref);
265
+ created = !existing.exists;
266
+ transaction.set(ref, _extends({
267
+ email: fields.email,
268
+ reason: UNSUBSCRIBE_SUPPRESSION_REASON,
269
+ suppressedAt: FieldValue.serverTimestamp()
270
+ }, existing.exists ? {} : _extends({
271
+ createdAt: FieldValue.serverTimestamp()
272
+ }, fields.campaignId ? {
273
+ campaignId: fields.campaignId
274
+ } : {}, fields.topicId ? {
275
+ topicId: fields.topicId
276
+ } : {})), {
277
+ merge: true
278
+ });
279
+ });
280
+ return created;
281
+ }
282
+ /**
283
+ * The self-service way back in (AGL-2499) that `unsubscribeHandler` never
284
+ * had: same signed-link shape, same safe-GET/mutating-POST split, same
285
+ * HMAC — a resubscribe link is only as trustworthy as the unsubscribe link
286
+ * it rides in on, so it earns no looser a contract.
287
+ *
288
+ * Reverses ONLY a self-service unsubscribe (`reason: 'unsubscribe'`). A
289
+ * bounce or spam-complaint suppression (`email-events.ts`'s Resend webhook)
290
+ * protects the SENDER's deliverability, not a preference the recipient can
291
+ * waive by clicking a link — undoing one from here would let anyone who
292
+ * still holds an old campaign email re-arm sending to an address that
293
+ * bounced or complained.
294
+ */ const resubscribeHandler = async (req, res)=>{
295
+ var _req_method;
296
+ const method = String((_req_method = req.method) != null ? _req_method : 'GET').toUpperCase();
297
+ if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {
298
+ res.setHeader('Allow', 'GET, POST');
299
+ return void res.status(405).send('Method not allowed');
300
+ }
301
+ // The SAME verifier the unsubscribe uses, because this link is minted by
302
+ // handing the unsubscribe's own signed query to a second route. Two
303
+ // implementations of one signature scheme is how the resubscribe link comes
304
+ // to reject a signature the unsubscribe link just accepted.
305
+ const opened = openSignedLink(req);
306
+ if (opened.refusal) {
307
+ return void res.status(opened.refusal).send('Invalid link');
308
+ }
309
+ const { params, key } = opened;
310
+ const { hostId, email } = params;
311
+ const query = signedQuery(params);
312
+ if (method !== 'POST') {
313
+ // SAFE, same reasoning as the unsubscribe GET: a prescanner must not be
314
+ // able to resubscribe someone either.
315
+ const brand = await loadHostBrand(hostId);
316
+ return void sendPage(res, page(heading('Resubscribe?') + paragraph(`Start receiving emails from <strong style="color:${PAL.ink}">` + `${escapeHtml(brand.name)}</strong> again at ` + `<strong style="color:${PAL.ink}">${escapeHtml(email)}</strong>.`) + `<form method="post" action="/api/email/resubscribe?${escapeHtml(query)}">` + submitButton('Resubscribe', {
317
+ accent: 'link',
318
+ pal: brand.pal
319
+ }) + '</form>', 420, brand));
320
+ }
321
+ try {
322
+ const firestore = firebaseAdmin.app().firestore();
323
+ const [released, brand] = await Promise.all([
324
+ releaseSiteSuppression(firestore, hostId, key),
325
+ loadHostBrand(hostId)
326
+ ]);
327
+ if (!released) {
328
+ return void sendPage(res, page(protectedAddressBody(), 420, brand));
329
+ }
330
+ return void sendPage(res, page(successBadge(brand.pal) + heading("You're resubscribed") + paragraph(`You'll receive emails from ${escapeHtml(brand.name)} again.`, 0), 420, brand));
331
+ } catch (error) {
332
+ console.error(error);
333
+ return void res.status(500).send('Resubscribe failed — please try again');
334
+ }
335
+ };
336
+ /**
337
+ * Lift a whole-site suppression, or refuse to.
338
+ *
339
+ * THE ONE RULE THAT IS NOT A PREFERENCE. A `bounce` or `complaint` entry says
340
+ * the mailbox is dead or its owner pressed "report spam", and neither is a
341
+ * setting the person on the other end of a link may change. Both are the
342
+ * sending domain's protection — one shared domain under `p=reject` for every
343
+ * tenant — so honoring a resubscribe over one would be handing the recipient a
344
+ * lever on somebody else's deliverability. Every path that puts an address
345
+ * back in circulation goes through here so that there is exactly one place the
346
+ * rule is stated.
347
+ *
348
+ * @returns false when the record was left standing because it is not an
349
+ * unsubscribe.
350
+ */ async function releaseSiteSuppression(firestore, hostId, key) {
351
+ const ref = firestore.collection('hosts').doc(hostId).collection('suppressions').doc(key);
352
+ const snapshot = await ref.get();
353
+ if (snapshot.exists && snapshot.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON) {
354
+ return false;
355
+ }
356
+ // Idempotent whether or not a doc existed — a resubscribe click on an
357
+ // address that was never suppressed (or already resubscribed) is not an
358
+ // error, it is the state the visitor wanted.
359
+ await ref.delete();
360
+ return true;
361
+ }
362
+ /** Shown wherever a resubscribe is refused, so the wording is one wording. */ function protectedAddressBody() {
363
+ return heading("Can't resubscribe this address") + paragraph('This address was suppressed by a delivery problem, not an ' + 'unsubscribe, so it can’t be re-added from this link. Contact ' + 'the site directly if this looks wrong.', 0);
364
+ }
365
+ /**
366
+ * THE PREFERENCE CENTER — the page the message footer links to.
367
+ *
368
+ * ## What it is for
369
+ *
370
+ * `docs/specs/email-competitive-gaps.md` §1f: every product compared has a
371
+ * preference center and we had one lever, marked all-or-nothing. The cost of
372
+ * that is not a missing feature, it is a misdirected one — the recipient who
373
+ * only wanted the sales mail to stop had to stop everything, and the recipient
374
+ * who did not want to stop everything pressed "report spam" instead, which is
375
+ * a complaint on a shared sending domain.
376
+ *
377
+ * ## What it may show, and what it must not
378
+ *
379
+ * Reached with no session, by anyone holding the link. The HMAC is what
380
+ * authorizes it, and it covers exactly the host, the address, the campaign and
381
+ * the topic — so the page shows the org's topic CATALOG and this address's
382
+ * opt-out state against it, and nothing else. Not the contact record, not the
383
+ * lists they are on, not their name, not whether we have ever heard of them.
384
+ *
385
+ * It is not an enumeration oracle for two reasons that both have to hold. A
386
+ * caller cannot ask about an address they do not already hold a signed link
387
+ * for; and the page renders IDENTICALLY for an address with no records at all
388
+ * — an unknown address reads as "subscribed to everything", which is both the
389
+ * truthful answer and the one that reveals nothing. There is deliberately no
390
+ * "we don't have that address" branch, because that branch is the oracle.
391
+ */ const preferencesHandler = async (req, res)=>{
392
+ var _req_method;
393
+ const method = String((_req_method = req.method) != null ? _req_method : 'GET').toUpperCase();
394
+ if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {
395
+ res.setHeader('Allow', 'GET, POST');
396
+ return void res.status(405).send('Method not allowed');
397
+ }
398
+ const opened = openSignedLink(req);
399
+ if (opened.refusal) {
400
+ return void res.status(opened.refusal).send('Invalid preferences link');
401
+ }
402
+ const { params, key } = opened;
403
+ const { hostId, email, campaignId, topicId } = params;
404
+ const query = signedQuery(params);
405
+ try {
406
+ var _req_body, _body_action;
407
+ const firestore = firebaseAdmin.app().firestore();
408
+ // Three independent reads, so they go together rather than in series —
409
+ // the brand is not worth a third round trip on the page a recipient is
410
+ // waiting for.
411
+ const [catalog, state, brand] = await Promise.all([
412
+ loadTopicCatalog(firestore, hostId),
413
+ readSubscriptionState(firestore, hostId, key),
414
+ loadHostBrand(hostId)
415
+ ]);
416
+ const topics = activeEmailTopics(catalog);
417
+ if (method !== 'POST') {
418
+ // SAFE. Reads only, exactly like the other two GETs.
419
+ return void sendPage(res, page(preferencesFormBody({
420
+ email,
421
+ query,
422
+ topics,
423
+ state,
424
+ topicId,
425
+ brand
426
+ }), 520, brand));
427
+ }
428
+ const body = (_req_body = req.body) != null ? _req_body : {};
429
+ if (String((_body_action = body['action']) != null ? _body_action : '') === 'all') {
430
+ const created = await writeSiteSuppression(firestore, hostId, key, {
431
+ email,
432
+ campaignId,
433
+ topicId
434
+ });
435
+ if (created && isCampaignPathId(campaignId)) {
436
+ await firestore.collection('hosts').doc(hostId).collection('campaigns').doc(campaignId).update({
437
+ 'stats.unsubscribes': FieldValue.increment(1)
438
+ }).catch(()=>undefined);
439
+ }
440
+ return void sendPage(res, page(successBadge(brand.pal) + heading('Sorry to see you go') + paragraph(`<strong style="color:${PAL.ink}">${escapeHtml(email)}</strong> ` + 'has been unsubscribed from every email ' + `${escapeHtml(brand.name)} sends.`, 20) + paragraph('Changed your mind? ' + `<a href="/api/email/resubscribe?${escapeHtml(query)}" ` + `style="color:${brand.pal.link};text-decoration:none">` + 'Resubscribe</a>, or ' + `<a href="/api/email/preferences?${escapeHtml(query)}" ` + `style="color:${brand.pal.link};text-decoration:none">` + 'pick just the emails you want</a>.', 0), 420, brand));
441
+ }
442
+ /*
443
+ * A CHECKED BOX MEANS "KEEP SENDING", so the opt-outs are the complement.
444
+ *
445
+ * Read off the catalog rather than off the form, deliberately. A browser
446
+ * submits nothing at all for an unchecked box, so a form that named only
447
+ * the boxes to TURN OFF would be indistinguishable from a form where the
448
+ * recipient turned everything off — and the two mean opposite things.
449
+ */ const keep = new Set(topics.map((topic)=>topic.id).filter((id)=>{
450
+ var _body_;
451
+ return String((_body_ = body[`topic:${id}`]) != null ? _body_ : '') !== '';
452
+ }));
453
+ const drop = topics.filter((topic)=>!keep.has(topic.id));
454
+ await writeTopicOptOuts(firestore, hostId, key, {
455
+ email,
456
+ optOut: drop.map((topic)=>topic.id),
457
+ resume: [
458
+ ...keep
459
+ ]
460
+ });
461
+ /*
462
+ * HOW OFTEN, recorded from the same submit as WHAT.
463
+ *
464
+ * They are one decision — "less of this, and less often" — so they are
465
+ * one form and one round trip. It is stored on the send counter rather
466
+ * than beside the topic opt-outs because that is the document the send
467
+ * path already reads for every marketing message, which is what makes
468
+ * honoring the request free at the point it has to be honored.
469
+ *
470
+ * A value that is not a cadence lands on `'all'` rather than erroring:
471
+ * this page is reached with no session by anybody holding the link, so
472
+ * `body` is untrusted, and the failure a recipient must not meet on the
473
+ * screen they came to in order to leave is a 500.
474
+ */ const cadence = normalizeMarketingCadence(body['cadence']);
475
+ const cadenceStored = await setMarketingCadence(hostId, email, cadence);
476
+ /*
477
+ * A person asking for SOME mail is asking not to be suppressed from ALL of
478
+ * it, so a whole-site unsubscribe standing against this address is lifted
479
+ * — through the same guard the resubscribe route uses, which refuses to
480
+ * touch a bounce or a complaint. Without this the page would accept a
481
+ * choice it could not honor: every box ticked, and the send path still
482
+ * dropping the address at the site suppression one layer above topics.
483
+ */ let stillBlocked = false;
484
+ if (keep.size) {
485
+ stillBlocked = !await releaseSiteSuppression(firestore, hostId, key);
486
+ }
487
+ return void sendPage(res, page(successBadge(brand.pal) + heading(drop.length ? 'Sorry to see you go' : 'Preferences saved') + paragraph(changeSummary({
488
+ email,
489
+ keep: [
490
+ ...keep
491
+ ],
492
+ drop,
493
+ topics
494
+ }), cadence === 'all' && cadenceStored ? 20 : 8) + /*
495
+ * The pace is reported only when it is a CHOICE. "As they come" is
496
+ * the default and the absence, so announcing it would tell somebody
497
+ * who touched nothing that they had just asked for something.
498
+ */ (cadence !== 'all' && cadenceStored ? paragraph(`They will arrive no more than ${cadenceSentence(cadence)}.`, 20) : '') + (!cadenceStored ? paragraph('One thing we could not change: how often these arrive. Your ' + 'other choices are saved — come back to this page to try ' + 'that one again.', 20) : '') + (stillBlocked ? paragraph('One thing we could not change: this address is on hold ' + 'because an earlier message could not be delivered or was ' + 'reported as spam. Contact the site directly if that looks ' + 'wrong.', 20) : '') + paragraph('Changed your mind? ' + `<a href="/api/email/preferences?${escapeHtml(query)}" ` + `style="color:${brand.pal.link};text-decoration:none">` + 'Come back to this page</a> and tick the boxes again — this ' + 'link keeps working.', 0), 520, brand));
499
+ } catch (error) {
500
+ console.error(error);
501
+ return void res.status(500).send('Preferences failed — please try again');
502
+ }
503
+ };
504
+ /**
505
+ * The org's topic catalog for a site.
506
+ *
507
+ * Two reads, both fail-soft to the built-in defaults. A site with no owning
508
+ * org, an org with no stored topics, or a Firestore hiccup all land on the
509
+ * same page: the four built-ins, every box ticked. That is the right failure —
510
+ * a preference page that renders NO topics offers the recipient nothing to
511
+ * uncheck, which turns the one screen they came to in order to leave a stream
512
+ * into a dead end.
513
+ */ async function loadTopicCatalog(firestore, hostId) {
514
+ try {
515
+ var _ref;
516
+ const orgId = await resolveOrgIdForHost(hostId);
517
+ if (!orgId) return mergeEmailTopics(null);
518
+ const snapshot = await firestore.collection('orgs').doc(orgId).collection(EMAIL_TOPICS_COLLECTION).get();
519
+ const stored = ((_ref = snapshot == null ? void 0 : snapshot.docs) != null ? _ref : []).map((doc)=>normalizeEmailTopic(doc.id, doc.data())).filter((topic)=>!!topic);
520
+ return mergeEmailTopics(stored);
521
+ } catch (error) {
522
+ console.error('[email/preferences] topic catalog read failed', error);
523
+ return mergeEmailTopics(null);
524
+ }
525
+ }
526
+ /** How a chosen cadence reads inside a sentence about what will happen. */ function cadenceSentence(cadence) {
527
+ return cadence === 'daily' ? 'one a day' : cadence === 'weekly' ? 'one a week' : 'one a month';
528
+ }
529
+ /**
530
+ * All three per-site records for one address, in three keyed `get()`s.
531
+ *
532
+ * By document id rather than a query, matching `filterSendableForHost`: no
533
+ * composite index to go missing, and nothing that can fail open on a read
534
+ * window. The third is the send counter, which is where the recipient's
535
+ * chosen pace lives — see `EmailFrequencyRecord.cadence` for why it is stored
536
+ * on the document the send path already reads rather than on this page's own.
537
+ */ async function readSubscriptionState(firestore, hostId, key) {
538
+ var _ref;
539
+ const hostRef = firestore.collection('hosts').doc(hostId);
540
+ const [suppression, optOuts, frequency] = await Promise.all([
541
+ hostRef.collection('suppressions').doc(key).get(),
542
+ hostRef.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key).get(),
543
+ hostRef.collection(EMAIL_FREQUENCY_SUBCOLLECTION).doc(key).get()// The pace is the one field on this page whose absence is a legitimate
544
+ // answer, so a read that fails renders the default rather than an
545
+ // error — the recipient still gets their topic checkboxes.
546
+ .catch(()=>null)
547
+ ]);
548
+ const stored = (_ref = (optOuts == null ? void 0 : optOuts.exists) ? optOuts.get('topics') : null) != null ? _ref : {};
549
+ const optedOut = new Set();
550
+ const pending = new Set();
551
+ for (const [id, record] of Object.entries(stored)){
552
+ /*
553
+ * The shared reader, not a field test. An entry with a `resubscribedAt`
554
+ * is EVIDENCE of an opt-out that has been lifted rather than a live one —
555
+ * see `writeTopicOptOuts` for why the entry stays — and an entry with a
556
+ * `confirmedAt` carries the same shape of evidence for a confirmation.
557
+ * Only one function knows all three states.
558
+ */ const state = readTopicSubscriptionState(record);
559
+ if (state === 'opted-out') optedOut.add(id);
560
+ if (state === 'pending') pending.add(id);
561
+ }
562
+ return {
563
+ suppressed: !!(suppression == null ? void 0 : suppression.exists),
564
+ protectedRecord: !!(suppression == null ? void 0 : suppression.exists) && suppression.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON,
565
+ optedOut,
566
+ pending,
567
+ cadence: normalizeMarketingCadence((frequency == null ? void 0 : frequency.exists) ? frequency.get('cadence') : null)
568
+ };
569
+ }
570
+ /**
571
+ * Record the recipient's per-topic choices.
572
+ *
573
+ * ## The record is EVIDENCE, so nothing is removed
574
+ *
575
+ * `email-suppression.ts` makes the argument for the suppression lists: "a
576
+ * revocation is a FIELD and not a delete, because the record is the evidence
577
+ * that the suppression was honored while it was in force." A topic opt-out is
578
+ * the same kind of fact — somebody asked us to stop, and the answer to "did
579
+ * you honor it" has to survive them changing their mind later. So rejoining a
580
+ * topic stamps `resubscribedAt` on the existing entry rather than deleting it,
581
+ * and the pair of timestamps is the window the request was in force for.
582
+ *
583
+ * One document per address, a map keyed by topic, rather than a document per
584
+ * (address, topic): the send path reads this by key alongside the suppression
585
+ * lists, and one `get()` per address is what keeps a topic-filtered send the
586
+ * same cost as an unfiltered one.
587
+ *
588
+ * ## Ticking a box here IS the confirmation a double opt-in asks for
589
+ *
590
+ * The entry also carries a pending-confirmation pair, and a recipient who
591
+ * ticks a topic on this page has done more than the confirmation link asks:
592
+ * they clicked a signed link delivered to that mailbox and then made a
593
+ * choice in it. Leaving them pending would mean the page recorded a
594
+ * subscription the send path refuses — a form whose submit does not take
595
+ * effect, which this page refuses to be anywhere else. So a resumed topic
596
+ * that is still pending is confirmed here, stamped with the moment they did
597
+ * it.
598
+ *
599
+ * ## Every write CARRIES the entry forward
600
+ *
601
+ * Each branch spreads the previous entry rather than replacing it. Two pairs
602
+ * of timestamps now live on one entry, and a branch that wrote only its own
603
+ * pair would silently discard the other — an opt-out would erase the record
604
+ * that somebody confirmed, and the erasure would look exactly like a person
605
+ * who never confirmed.
606
+ */ async function writeTopicOptOuts(firestore, hostId, key, fields) {
607
+ const ref = firestore.collection('hosts').doc(hostId).collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key);
608
+ await firestore.runTransaction(async (transaction)=>{
609
+ var _ref;
610
+ const existing = await transaction.get(ref);
611
+ const stored = (_ref = existing.exists ? existing.get('topics') : null) != null ? _ref : {};
612
+ const topics = {};
613
+ for (const id of fields.optOut){
614
+ const previous = stored[id];
615
+ /*
616
+ * Already opted out and never rejoined: leave the original timestamp
617
+ * alone. Re-submitting the same form must not restamp the date the
618
+ * person actually left, for the reason `createdAt` is not restamped on
619
+ * the suppression.
620
+ *
621
+ * The state reader, not "an entry with no `resubscribedAt`". That
622
+ * shorthand reads a CONFIRMED double opt-in — which carries `pendingAt`
623
+ * and `confirmedAt` and no `resubscribedAt` — as somebody who had
624
+ * already left, so unticking their box would record no opt-out at all
625
+ * and the send path would go on mailing them.
626
+ */ topics[id] = readTopicSubscriptionState(previous) === 'opted-out' ? previous : _extends({}, previous != null ? previous : {}, {
627
+ optedOutAt: FieldValue.serverTimestamp(),
628
+ resubscribedAt: null
629
+ });
630
+ }
631
+ for (const id of fields.resume){
632
+ const previous = stored[id];
633
+ if (!previous) continue;
634
+ const state = readTopicSubscriptionState(previous);
635
+ if (state === 'pending') {
636
+ topics[id] = _extends({}, previous, {
637
+ confirmedAt: Date.now()
638
+ });
639
+ continue;
640
+ }
641
+ topics[id] = previous['resubscribedAt'] ? previous : _extends({}, previous, {
642
+ resubscribedAt: FieldValue.serverTimestamp()
643
+ });
644
+ }
645
+ transaction.set(ref, _extends({
646
+ email: fields.email,
647
+ // The whole map, not a merge of one key: a topic the recipient
648
+ // rejoined has to lose its live status, and a dotted merge cannot
649
+ // express "these and no others" for a map whose keys are data.
650
+ topics: _extends({}, stored, topics),
651
+ updatedAt: FieldValue.serverTimestamp()
652
+ }, existing.exists ? {} : {
653
+ createdAt: FieldValue.serverTimestamp()
654
+ }), {
655
+ merge: true
656
+ });
657
+ });
658
+ }
659
+ /** One topic row: a checkbox, its name and its description. */ function topicRow(topic, checked, highlighted, /**
660
+ * Asked to confirm and has not.
661
+ *
662
+ * The box is EMPTY for a pending topic, because empty is the truth: the
663
+ * send path refuses this stream until it is confirmed, and a ticked box
664
+ * over a stream nothing will send would be the page telling a lie the
665
+ * recipient can only discover by waiting for mail that never comes. The
666
+ * note beside it is what turns "not ticked" from a puzzle into an answer,
667
+ * and ticking it here confirms — see `writeTopicOptOuts`.
668
+ */ pending = false, pal = PAL) {
669
+ return `<label style="display:flex;gap:12px;align-items:flex-start;padding:14px 0;` + `border-top:1px solid ${PAL.divider};cursor:pointer">` + `<input type="checkbox" name="topic:${escapeHtml(topic.id)}" value="on"` + (checked ? ' checked' : '') + ' style="margin:2px 0 0;width:18px;height:18px;flex:none">' + '<span style="flex:1">' + `<span style="display:block;font-size:14px;font-weight:600;color:${PAL.ink}">` + escapeHtml(topic.name) + (highlighted ? `<span style="margin-left:8px;font-size:11px;font-weight:600;` + `text-transform:uppercase;letter-spacing:.04em;color:${pal.link}">` + 'This email</span>' : '') + '</span>' + (pending ? `<span style="display:block;margin-top:2px;font-size:13px;line-height:1.45;` + `color:${PAL.muted}">Waiting for you to confirm — tick this and save ` + 'to start receiving it.</span>' : '') + (topic.description ? `<span style="display:block;margin-top:2px;font-size:13px;line-height:1.45;` + `color:${PAL.muted}">${escapeHtml(topic.description)}</span>` : '') + '</span></label>';
670
+ }
671
+ /**
672
+ * HOW OFTEN — the half of the preference center that shipped without.
673
+ *
674
+ * `docs/specs/email-competitive-gaps.md` G10: the frequency CAP shipped and
675
+ * this did not, so a recipient who wanted the same mail less often had two
676
+ * options and one of them was the spam button.
677
+ *
678
+ * Radio buttons rather than a select, and every option written out. The whole
679
+ * value of the control is that somebody skimming a footer link can see, in
680
+ * one glance, that "less" is available at all — a collapsed select says only
681
+ * that there is a setting.
682
+ *
683
+ * The default option is named ("As they come") rather than left as the empty
684
+ * choice, because a radio group whose default is unlabeled reads as a
685
+ * question the recipient has not answered, and answering it is not something
686
+ * this page should require of somebody who came here to uncheck one box.
687
+ */ function cadenceFieldset(current) {
688
+ const option = (value, label)=>`<label style="display:flex;gap:12px;align-items:center;padding:10px 0;cursor:pointer">` + `<input type="radio" name="cadence" value="${escapeHtml(value)}"` + (value === current ? ' checked' : '') + ' style="margin:0;width:18px;height:18px;flex:none">' + `<span style="font-size:14px;color:${PAL.ink}">${label}</span></label>`;
689
+ return `<div style="border-top:1px solid ${PAL.divider};padding-top:18px;margin-top:6px">` + `<div style="font-size:14px;font-weight:600;color:${PAL.ink};margin-bottom:2px">` + 'How often' + '</div>' + `<div style="font-size:13px;line-height:1.45;color:${PAL.muted};margin-bottom:6px">` + 'This applies to everything above. Nothing is canceled — messages just ' + 'wait until the next one is due.' + '</div>' + option('all', 'As they come') + option('daily', 'At most one a day') + option('weekly', 'At most one a week') + option('monthly', 'At most one a month') + '</div>';
690
+ }
691
+ /** The preference page's body. */ function preferencesFormBody(args) {
692
+ const { email, query, topics, state, topicId, brand } = args;
693
+ const pal = brand.pal;
694
+ // A bounce or a complaint is not a preference, so the page does not pretend
695
+ // the recipient can edit their way out of one. Shown instead of the form
696
+ // rather than beside it: a form whose submit cannot take effect is worse
697
+ // than no form.
698
+ if (state.protectedRecord) return protectedAddressBody();
699
+ const current = resolveCampaignTopic(topicId, topics);
700
+ const action = `/api/email/preferences?${escapeHtml(query)}`;
701
+ return heading('Email preferences') + paragraph(`Choose what <strong style="color:${PAL.ink}">${escapeHtml(email)}</strong> should keep receiving from ` + `<strong style="color:${PAL.ink}">${escapeHtml(brand.name)}</strong>. Unticked emails stop; everything else carries on.`, 8) + (state.suppressed ? paragraph('You are currently unsubscribed from everything. Tick anything ' + 'below to start receiving it again.', 8) : '') + `<form method="post" action="${action}">` + topics.map((topic)=>topicRow(topic, // A whole-site suppression outranks the per-topic record, so an
702
+ // unsubscribed recipient sees every box empty — which is the state
703
+ // they are actually in, and the state the form must round-trip. An
704
+ // unconfirmed topic is empty for the same reason: the send path
705
+ // refuses it, so a ticked box would not be what is true.
706
+ !state.suppressed && !state.optedOut.has(topic.id) && !state.pending.has(topic.id), topic.id === current.id, !state.suppressed && state.pending.has(topic.id), pal)).join('') + /*
707
+ * HOW OFTEN, inside the same form as WHAT.
708
+ *
709
+ * The alternative to letting somebody choose "monthly" is letting them
710
+ * choose "report spam", and on a shared sending domain under `p=reject`
711
+ * that choice is charged to every other tenant. It sits under the topics
712
+ * because it is the smaller decision of the two and a recipient who has
713
+ * already found the thing they wanted to stop should not have to read
714
+ * past a frequency question to stop it.
715
+ */ cadenceFieldset(state.cadence) + `<div style="border-top:1px solid ${PAL.divider};padding-top:20px;margin-top:6px">` + submitButton('Save my preferences', {
716
+ pal
717
+ }) + '</div></form>' + // A SECOND form, not a second button in the first one. Sharing the form
718
+ // would submit the checkbox state along with the "everything" action, so a
719
+ // browser that fell back to the first submit button — or a user pressing
720
+ // Return in the form — would send an ambiguous request. Two forms make the
721
+ // two intentions two requests.
722
+ `<form method="post" action="${action}" style="margin-top:12px">` + '<input type="hidden" name="action" value="all">' + '<button type="submit" style="font:inherit;font-size:13px;font-weight:600;' + `padding:10px 20px;border:1px solid ${PAL.divider};border-radius:8px;` + `background:transparent;color:${PAL.muted};cursor:pointer;width:100%">` + 'Unsubscribe from everything</button></form>';
723
+ }
724
+ /** What the result page tells the recipient actually changed. */ function changeSummary(args) {
725
+ const address = `<strong style="color:${PAL.ink}">${escapeHtml(args.email)}</strong>`;
726
+ if (!args.drop.length) {
727
+ return `${address} keeps receiving everything this site sends.`;
728
+ }
729
+ const names = args.drop.map((topic)=>escapeHtml(topic.name)).join(', ');
730
+ if (!args.keep.length) {
731
+ return `${address} has been unsubscribed from ${names} — everything this ` + 'site currently sends.';
732
+ }
733
+ return `${address} will stop receiving ${names}, and keeps the rest.`;
734
+ }
735
+ /**
736
+ * `email/confirm` — the click that turns a pending subscription into a real
737
+ * one (`docs/specs/email-competitive-gaps.md` P8).
738
+ *
739
+ * Same signed-link shape as its three siblings and the same safe-GET /
740
+ * mutating-POST split, which matters here for exactly the reason it mattered
741
+ * to the unsubscribe: a security gateway fetching every URL in the message
742
+ * would otherwise confirm the subscription on the recipient's behalf, and a
743
+ * confirmation nobody made is the one thing a double opt-in exists to
744
+ * prevent. A prescanner following this link renders a page and changes
745
+ * nothing.
746
+ *
747
+ * The subject it verifies is the confirmation form — see
748
+ * `signedConfirmSubject` for why a topic without a campaign needs one — and
749
+ * it is checked through the same comparison every other link goes through.
750
+ */ const confirmHandler = async (req, res)=>{
751
+ var _req_method;
752
+ const method = String((_req_method = req.method) != null ? _req_method : 'GET').toUpperCase();
753
+ if (method !== 'GET' && method !== 'HEAD' && method !== 'POST') {
754
+ res.setHeader('Allow', 'GET, POST');
755
+ return void res.status(405).send('Method not allowed');
756
+ }
757
+ const params = readParams(req);
758
+ const secret = linkSecret();
759
+ if (!params.hostId || !params.email || !params.signature || !secret) {
760
+ return void res.status(400).send('Invalid confirmation link');
761
+ }
762
+ if (!signatureMatches(_extends({}, params, {
763
+ secret,
764
+ purpose: 'confirm'
765
+ }))) {
766
+ return void res.status(403).send('Invalid confirmation link');
767
+ }
768
+ if (!suppressionKeyFor(params.email)) {
769
+ return void res.status(400).send('Invalid confirmation link');
770
+ }
771
+ const { hostId, email, topicId } = params;
772
+ const query = signedQuery(params);
773
+ try {
774
+ const firestore = firebaseAdmin.app().firestore();
775
+ const [catalog, brand] = await Promise.all([
776
+ loadTopicCatalog(firestore, hostId),
777
+ loadHostBrand(hostId)
778
+ ]);
779
+ const topic = resolveCampaignTopic(topicId, catalog);
780
+ if (method !== 'POST') {
781
+ // SAFE. A prescanner lands here and confirms nothing.
782
+ return void sendPage(res, page(heading('Confirm your subscription') + paragraph(`Confirm that <strong style="color:${PAL.ink}">${escapeHtml(email)}</strong> should receive ` + `<strong style="color:${PAL.ink}">${escapeHtml(topic.name)}</strong> from ` + `<strong style="color:${PAL.ink}">${escapeHtml(brand.name)}</strong>.`) + `<form method="post" action="/api/email/confirm?${escapeHtml(query)}">` + submitButton('Yes, subscribe me', {
783
+ pal: brand.pal
784
+ }) + '</form>', 420, brand));
785
+ }
786
+ const outcome = await confirmTopicSubscription(hostId, email, topicId);
787
+ return void sendPage(res, page(confirmationBody(outcome, topic.name, brand.pal), 420, brand));
788
+ } catch (error) {
789
+ console.error(error);
790
+ return void res.status(500).send('Confirmation failed — please try again');
791
+ }
792
+ };
793
+ /**
794
+ * What each outcome tells the person in front of it.
795
+ *
796
+ * Every arm names what is TRUE rather than what went wrong. Somebody who
797
+ * clicked an expired link has not made a mistake, and somebody who clicked
798
+ * twice has not either — telling either of them "invalid" would read as the
799
+ * subscription having failed when the first case needs a fresh signup and the
800
+ * second is already done.
801
+ */ function confirmationBody(outcome, topicName, pal = PAL) {
802
+ const stream = `<strong style="color:${PAL.ink}">${escapeHtml(topicName)}</strong>`;
803
+ switch(outcome){
804
+ case 'confirmed':
805
+ return successBadge(pal) + heading("You're subscribed") + paragraph(`You'll start receiving ${stream} from this site.`, 0);
806
+ case 'already-confirmed':
807
+ return successBadge(pal) + heading('Already confirmed') + paragraph(`${stream} is already on its way to you.`, 0);
808
+ case 'expired':
809
+ return heading('This link has expired') + paragraph(`Confirmation links are good for three days. Sign up again and ` + `we'll send a fresh one — you are not subscribed to ${stream} in ` + 'the meantime.', 0);
810
+ case 'opted-out':
811
+ return heading("Can't subscribe this address") + paragraph(`This address asked to stop receiving ${stream} from this site, so ` + 'a confirmation link cannot put it back. Sign up again if that ' + 'was not what you meant.', 0);
812
+ default:
813
+ return heading('Nothing to confirm') + paragraph(`There is no pending request for ${stream} at this address. If you ` + 'meant to subscribe, sign up on the site.', 0);
814
+ }
815
+ }
816
+ /** Registers the email plugin's public API routes (AGL-396). */ export function registerEmailApi() {
817
+ registerPluginApiRoute('email/unsubscribe', unsubscribeHandler);
818
+ registerPluginApiRoute('email/resubscribe', resubscribeHandler);
819
+ registerPluginApiRoute('email/preferences', preferencesHandler);
820
+ registerPluginApiRoute('email/confirm', confirmHandler);
821
+ }
822
+ /*
823
+ * The CONSOLE half of the same `email` prefix, kept in its own module.
824
+ *
825
+ * Two audiences, one entry point: the tenant loads this file for
826
+ * `registerEmailApi` (the signed unsubscribe links a recipient clicks, no
827
+ * session behind them), and the console loads it for
828
+ * `registerEmailConsoleApi` (list membership, behind an org-wide role). The
829
+ * manifest generator resolves both surfaces through `@aglyn/plugins-email/server`,
830
+ * so this re-export is what makes the console half reachable — a second entry
831
+ * point would be a second thing to keep in step with plugins.config.json.
832
+ */ export { registerEmailConsoleApi, emailListMembersAddHandler, emailListMembersPreviewHandler, emailListRulePreviewHandler, CONSOLE_ADD_SOURCE, LIST_MEMBER_BATCH_MAX } from "./server-console.js";
833
+
834
+ //# sourceMappingURL=server.js.map