@aglyn/plugins-email 1.0.0-beta.182 → 1.0.0-beta.184

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.
package/src/lib/server.js CHANGED
@@ -22,7 +22,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
22
22
  * module is a bundle `app-router-graph.spec.ts` refuses. Every name here is a
23
23
  * pure function or a constant that lives in one leaf file.
24
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, mirrorPlatformResubscribe, mirrorPlatformUnsubscribe, resolveCampaignSendRef, resolveOrgIdForHost, setMarketingCadence, UNSUBSCRIBE_SUPPRESSION_REASON } from "@aglyn/tenant-data-admin";
25
+ import { consentGroupOptOutHosts, soloConsentGroup } from "@aglyn/aglyn/app-utils/consent-groups";
26
+ import { confirmTopicSubscription, consentGroupForSite, EMAIL_FREQUENCY_SUBCOLLECTION, firebaseAdmin, mirrorPlatformResubscribe, mirrorPlatformUnsubscribe, resolveCampaignSendRef, resolveOrgIdForHost, setMarketingCadence, UNSUBSCRIBE_SUPPRESSION_REASON } from "@aglyn/tenant-data-admin";
26
27
  import { stampRecordEmailState } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
27
28
  /*
28
29
  * The pure cadence rule from the shared email library, where the SEND path
@@ -165,6 +166,65 @@ function openSignedLink(req) {
165
166
  if (timer) clearTimeout(timer);
166
167
  }
167
168
  }
169
+ /**
170
+ * THE SENDER A PERSON LEAVES HERE — the link site's consent group
171
+ * (`consent-groups.ts`), or the site alone.
172
+ *
173
+ * An org may declare several sites ONE sender, and every capture form in the
174
+ * group named it as one. So these pages treat it as one: an opt-out made here
175
+ * is read by every site in it (the send paths read across the group), the
176
+ * state shown is the group's, a way back in lifts the group's records, and
177
+ * the page names the group rather than one of its sites.
178
+ *
179
+ * `consentGroupForSite` fails to the site alone, and so does this when it
180
+ * throws. `waitMs` bounds the wait where a page only NAMES the sender, for the
181
+ * reason {@link BRAND_READ_TIMEOUT_MS} gives; a page deciding what to read or
182
+ * lift waits for the real answer.
183
+ */ async function loadConsentGroup(hostId, waitMs) {
184
+ const alone = soloConsentGroup(hostId);
185
+ const resolving = (async ()=>{
186
+ try {
187
+ return await consentGroupForSite(hostId);
188
+ } catch (error) {
189
+ console.error('[email] consent group read failed', error);
190
+ return alone;
191
+ }
192
+ })();
193
+ if (!waitMs) return resolving;
194
+ let timer;
195
+ try {
196
+ return await Promise.race([
197
+ resolving,
198
+ new Promise((resolve)=>{
199
+ timer = setTimeout(()=>resolve(alone), waitMs);
200
+ })
201
+ ]);
202
+ } finally{
203
+ if (timer) clearTimeout(timer);
204
+ }
205
+ }
206
+ /**
207
+ * The sender as a page names it.
208
+ *
209
+ * The group's name is what the capture form disclosed — "You'll receive
210
+ * marketing email from" the group — so it is the name the person knows this
211
+ * sender by, and the one an opt-out here reaches. The site's own brand still
212
+ * frames the page; `reach` is what stops the two names reading as a mistake.
213
+ */ function pageSender(brand, group) {
214
+ if (!group.declared || !group.name) {
215
+ return {
216
+ name: escapeHtml(brand.name),
217
+ reach: ''
218
+ };
219
+ }
220
+ return {
221
+ name: escapeHtml(group.name),
222
+ reach: `${escapeHtml(group.name)} sends from ${group.hostIds.length} sites, ` + `${escapeHtml(brand.name)} among them, and this covers all of them.`
223
+ };
224
+ }
225
+ /** The `reach` sentence as a paragraph, or nothing for a site alone. */ function reachParagraph(sender, gap = 20) {
226
+ return sender.reach ? paragraph(sender.reach, gap) : '';
227
+ }
168
228
  const unsubscribeHandler = async (req, res)=>{
169
229
  var _req_method;
170
230
  const method = String((_req_method = req.method) != null ? _req_method : 'GET').toUpperCase();
@@ -180,10 +240,15 @@ const unsubscribeHandler = async (req, res)=>{
180
240
  const { hostId, email, campaignId, topicId } = params;
181
241
  const query = signedQuery(params);
182
242
  if (method !== 'POST') {
183
- // SAFE. A prescanner lands here and nothing is written — the brand read
184
- // is the only Firestore access on this path, and it is a read.
185
- const brand = await loadHostBrand(hostId);
186
- 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', {
243
+ // SAFE. A prescanner lands here and nothing is written — the brand and
244
+ // the consent group are the only Firestore access on this path, and both
245
+ // are reads.
246
+ const [brand, group] = await Promise.all([
247
+ loadHostBrand(hostId),
248
+ loadConsentGroup(hostId, BRAND_READ_TIMEOUT_MS)
249
+ ]);
250
+ const sender = pageSender(brand, group);
251
+ 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}">${sender.name}</strong>.`, sender.reach ? 8 : 24) + reachParagraph(sender, 24) + `<form method="post" action="/api/email/unsubscribe?${escapeHtml(query)}">` + submitButton('Unsubscribe', {
187
252
  pal: brand.pal
188
253
  }) + '</form>' + // The way to a NARROWER choice, offered on the page rather than only
189
254
  // in the message footer: a recipient who reached the total
@@ -193,14 +258,21 @@ const unsubscribeHandler = async (req, res)=>{
193
258
  }
194
259
  try {
195
260
  const firestore = firebaseAdmin.app().firestore();
196
- const [created, brand] = await Promise.all([
261
+ /*
262
+ * Written against the ONE site the link names, and honored by every site
263
+ * in its consent group: the send paths read a group's lists together, so
264
+ * the one row is the refusal for the whole sender — and it reaches a site
265
+ * that joins the group later, which a copy written now could not.
266
+ */ const [created, brand, group] = await Promise.all([
197
267
  writeSiteSuppression(firestore, hostId, key, {
198
268
  email,
199
269
  campaignId,
200
270
  topicId
201
271
  }),
202
- loadHostBrand(hostId)
272
+ loadHostBrand(hostId),
273
+ loadConsentGroup(hostId, BRAND_READ_TIMEOUT_MS)
203
274
  ]);
275
+ const sender = pageSender(brand, group);
204
276
  /*
205
277
  * The campaign's own unsubscribe count.
206
278
  *
@@ -219,7 +291,7 @@ const unsubscribeHandler = async (req, res)=>{
219
291
  email,
220
292
  left: 'everything'
221
293
  });
222
- 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
294
+ return void sendPage(res, page(successBadge(brand.pal) + heading("You're unsubscribed") + paragraph(`You won't receive further emails from ${sender.name}.`, sender.reach ? 8 : 20) + reachParagraph(sender) + // Same signed params, so the click that just proved this is really
223
295
  // this recipient's link doubles as the resubscribe link — no new
224
296
  // token, no second email round-trip (AGL-2499).
225
297
  `<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));
@@ -359,17 +431,24 @@ const unsubscribeHandler = async (req, res)=>{
359
431
  if (method !== 'POST') {
360
432
  // SAFE, same reasoning as the unsubscribe GET: a prescanner must not be
361
433
  // able to resubscribe someone either.
362
- const brand = await loadHostBrand(hostId);
363
- 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', {
434
+ const [brand, group] = await Promise.all([
435
+ loadHostBrand(hostId),
436
+ loadConsentGroup(hostId, BRAND_READ_TIMEOUT_MS)
437
+ ]);
438
+ const sender = pageSender(brand, group);
439
+ return void sendPage(res, page(heading('Resubscribe?') + paragraph(`Start receiving emails from <strong style="color:${PAL.ink}">` + `${sender.name}</strong> again at ` + `<strong style="color:${PAL.ink}">${escapeHtml(email)}</strong>.`, sender.reach ? 8 : 24) + reachParagraph(sender, 24) + `<form method="post" action="/api/email/resubscribe?${escapeHtml(query)}">` + submitButton('Resubscribe', {
364
440
  accent: 'link',
365
441
  pal: brand.pal
366
442
  }) + '</form>', 420, brand));
367
443
  }
368
444
  try {
369
445
  const firestore = firebaseAdmin.app().firestore();
370
- const [released, brand] = await Promise.all([
371
- releaseSiteSuppression(firestore, hostId, key),
372
- loadHostBrand(hostId)
446
+ // The group decides which rows are lifted, so it is waited for in full.
447
+ const groupRead = loadConsentGroup(hostId);
448
+ const [released, brand, group] = await Promise.all([
449
+ groupRead.then((resolved)=>releaseSiteSuppression(firestore, resolved, key)),
450
+ loadHostBrand(hostId),
451
+ groupRead
373
452
  ]);
374
453
  if (!released) {
375
454
  return void sendPage(res, page(protectedAddressBody(), 420, brand));
@@ -381,7 +460,8 @@ const unsubscribeHandler = async (req, res)=>{
381
460
  email,
382
461
  via: 'email-resubscribe'
383
462
  });
384
- 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));
463
+ const sender = pageSender(brand, group);
464
+ return void sendPage(res, page(successBadge(brand.pal) + heading("You're resubscribed") + paragraph(`You'll receive emails from ${sender.name} again.`, sender.reach ? 8 : 0) + reachParagraph(sender, 0), 420, brand));
385
465
  } catch (error) {
386
466
  console.error(error);
387
467
  return void res.status(500).send('Resubscribe failed — please try again');
@@ -399,18 +479,39 @@ const unsubscribeHandler = async (req, res)=>{
399
479
  * back in circulation goes through here so that there is exactly one place the
400
480
  * rule is stated.
401
481
  *
402
- * @returns false when the record was left standing because it is not an
482
+ * ## Across the consent group, all or nothing
483
+ *
484
+ * The send paths read every site's list in the link site's consent group, so
485
+ * an unsubscribe filed on a sibling holds this site's mail too — and a way
486
+ * back in that lifted only this site's row would tell the person they were
487
+ * resubscribed while the sender went on withholding. Every site's row is
488
+ * therefore read first, and the release happens only if none of them is a
489
+ * record this rule may not lift: one bounce or complaint anywhere in the
490
+ * group still holds the whole group, so lifting the unsubscribes beside it
491
+ * would change nothing the person could see, and the page says so instead.
492
+ *
493
+ * @returns false when a record was left standing because it is not an
403
494
  * unsubscribe.
404
- */ async function releaseSiteSuppression(firestore, hostId, key) {
405
- const ref = firestore.collection('hosts').doc(hostId).collection('suppressions').doc(key);
406
- const snapshot = await ref.get();
407
- if (snapshot.exists && snapshot.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON) {
495
+ */ async function releaseSiteSuppression(firestore, group, key) {
496
+ const [own, ...siblings] = consentGroupOptOutHosts(group).map((id)=>firestore.collection('hosts').doc(id).collection('suppressions').doc(key));
497
+ const [ownSnapshot, ...siblingSnapshots] = await Promise.all([
498
+ own,
499
+ ...siblings
500
+ ].map((ref)=>ref.get()));
501
+ if ([
502
+ ownSnapshot,
503
+ ...siblingSnapshots
504
+ ].some((snapshot)=>snapshot.exists && snapshot.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON)) {
408
505
  return false;
409
506
  }
410
507
  // Idempotent whether or not a doc existed — a resubscribe click on an
411
508
  // address that was never suppressed (or already resubscribed) is not an
412
- // error, it is the state the visitor wanted.
413
- await ref.delete();
509
+ // error, it is the state the visitor wanted. A sibling's row is deleted
510
+ // only where one stands.
511
+ await Promise.all([
512
+ own.delete(),
513
+ ...siblings.filter((_ref, index)=>siblingSnapshots[index].exists).map((ref)=>ref.delete())
514
+ ]);
414
515
  return true;
415
516
  }
416
517
  /** Shown wherever a resubscribe is refused, so the wording is one wording. */ function protectedAddressBody() {
@@ -459,15 +560,18 @@ const unsubscribeHandler = async (req, res)=>{
459
560
  try {
460
561
  var _req_body, _body_action;
461
562
  const firestore = firebaseAdmin.app().firestore();
462
- // Three independent reads, so they go together rather than in series —
463
- // the brand is not worth a third round trip on the page a recipient is
464
- // waiting for.
465
- const [catalog, state, brand] = await Promise.all([
563
+ // Independent reads, so they go together rather than in series — the
564
+ // brand is not worth another round trip on the page a recipient is
565
+ // waiting for. The state is the consent GROUP's, so it waits on the group.
566
+ const groupRead = loadConsentGroup(hostId);
567
+ const [catalog, state, brand, group] = await Promise.all([
466
568
  loadTopicCatalog(firestore, hostId),
467
- readSubscriptionState(firestore, hostId, key),
468
- loadHostBrand(hostId)
569
+ groupRead.then((resolved)=>readSubscriptionState(firestore, resolved, key)),
570
+ loadHostBrand(hostId),
571
+ groupRead
469
572
  ]);
470
573
  const topics = activeEmailTopics(catalog);
574
+ const sender = pageSender(brand, group);
471
575
  if (method !== 'POST') {
472
576
  // SAFE. Reads only, exactly like the other two GETs.
473
577
  return void sendPage(res, page(preferencesFormBody({
@@ -476,7 +580,8 @@ const unsubscribeHandler = async (req, res)=>{
476
580
  topics,
477
581
  state,
478
582
  topicId,
479
- brand
583
+ brand,
584
+ sender
480
585
  }), 520, brand));
481
586
  }
482
587
  const body = (_req_body = req.body) != null ? _req_body : {};
@@ -492,7 +597,7 @@ const unsubscribeHandler = async (req, res)=>{
492
597
  email,
493
598
  left: 'everything'
494
599
  });
495
- 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));
600
+ 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 ' + `${sender.name} sends.`, sender.reach ? 8 : 20) + reachParagraph(sender) + 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));
496
601
  }
497
602
  /*
498
603
  * A CHECKED BOX MEANS "KEEP SENDING", so the opt-outs are the complement.
@@ -514,6 +619,15 @@ const unsubscribeHandler = async (req, res)=>{
514
619
  ]
515
620
  });
516
621
  /*
622
+ * A ticked box is a stream the person wants back FROM THE SENDER, and the
623
+ * send paths read an opt-out on any site of the consent group — so an
624
+ * opt-out a sibling holds is lifted too, or the box would be a choice the
625
+ * send path goes on refusing. The unticked ones are written here alone:
626
+ * this site's record is read across the group already.
627
+ */ await resumeTopicsAcrossGroup(firestore, group, key, [
628
+ ...keep
629
+ ]);
630
+ /*
517
631
  * HOW OFTEN, recorded from the same submit as WHAT.
518
632
  *
519
633
  * They are one decision — "less of this, and less often" — so they are
@@ -537,7 +651,7 @@ const unsubscribeHandler = async (req, res)=>{
537
651
  * dropping the address at the site suppression one layer above topics.
538
652
  */ let stillBlocked = false;
539
653
  if (keep.size) {
540
- stillBlocked = !await releaseSiteSuppression(firestore, hostId, key);
654
+ stillBlocked = !await releaseSiteSuppression(firestore, group, key);
541
655
  }
542
656
  /*
543
657
  * The account's answer about product updates follows what this page just
@@ -566,8 +680,9 @@ const unsubscribeHandler = async (req, res)=>{
566
680
  ...keep
567
681
  ],
568
682
  drop,
569
- topics
570
- }), cadence === 'all' && cadenceStored ? 20 : 8) + /*
683
+ topics,
684
+ sender
685
+ }), cadence === 'all' && cadenceStored && !sender.reach ? 20 : 8) + reachParagraph(sender, cadence === 'all' && cadenceStored ? 20 : 8) + /*
571
686
  * The pace is reported only when it is a CHOICE. "As they come" is
572
687
  * the default and the absence, so announcing it would tell somebody
573
688
  * who touched nothing that they had just asked for something.
@@ -608,44 +723,82 @@ const unsubscribeHandler = async (req, res)=>{
608
723
  return cadence === 'daily' ? 'one a day' : cadence === 'weekly' ? 'one a week' : 'one a month';
609
724
  }
610
725
  /**
611
- * All three per-site records for one address, in three keyed `get()`s.
726
+ * All three per-site records for one address, in three keyed `get()`s per
727
+ * site of the link site's consent group.
612
728
  *
613
729
  * By document id rather than a query, matching `filterSendableForHost`: no
614
730
  * composite index to go missing, and nothing that can fail open on a read
615
731
  * window. The third is the send counter, which is where the recipient's
616
732
  * chosen pace lives — see `EmailFrequencyRecord.cadence` for why it is stored
617
733
  * on the document the send path already reads rather than on this page's own.
618
- */ async function readSubscriptionState(firestore, hostId, key) {
619
- var _ref;
620
- const hostRef = firestore.collection('hosts').doc(hostId);
621
- const [suppression, optOuts, frequency] = await Promise.all([
622
- hostRef.collection('suppressions').doc(key).get(),
623
- hostRef.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key).get(),
624
- hostRef.collection(EMAIL_FREQUENCY_SUBCOLLECTION).doc(key).get()// The pace is the one field on this page whose absence is a legitimate
625
- // answer, so a read that fails renders the default rather than an
626
- // error — the recipient still gets their topic checkboxes.
627
- .catch(()=>null)
628
- ]);
629
- const stored = (_ref = (optOuts == null ? void 0 : optOuts.exists) ? optOuts.get('topics') : null) != null ? _ref : {};
734
+ *
735
+ * ## The group's state, read the way the send paths read it
736
+ *
737
+ * The page shows what the SENDER will do, and the sender is the consent
738
+ * group: a suppression standing on any of its sites, and a stream left on any
739
+ * of them, holds this site's mail too, so both show here. The pace is the one
740
+ * chosen most recently on any site's page. A pending confirmation is the link
741
+ * site's own, as it is on the send path. A group of one is the three reads
742
+ * this page always made.
743
+ */ async function readSubscriptionState(firestore, group, key) {
744
+ const sites = await Promise.all(consentGroupOptOutHosts(group).map((id)=>{
745
+ const hostRef = firestore.collection('hosts').doc(id);
746
+ return Promise.all([
747
+ hostRef.collection('suppressions').doc(key).get(),
748
+ hostRef.collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key).get(),
749
+ hostRef.collection(EMAIL_FREQUENCY_SUBCOLLECTION).doc(key).get()// The pace is the one field on this page whose absence is a
750
+ // legitimate answer, so a read that fails renders the default
751
+ // rather than an error — the recipient still gets their topic
752
+ // checkboxes.
753
+ .catch(()=>null)
754
+ ]);
755
+ }));
630
756
  const optedOut = new Set();
631
757
  const pending = new Set();
632
- for (const [id, record] of Object.entries(stored)){
633
- /*
634
- * The shared reader, not a field test. An entry with a `resubscribedAt`
635
- * is EVIDENCE of an opt-out that has been lifted rather than a live one —
636
- * see `writeTopicOptOuts` for why the entry stays — and an entry with a
637
- * `confirmedAt` carries the same shape of evidence for a confirmation.
638
- * Only one function knows all three states.
639
- */ const state = readTopicSubscriptionState(record);
640
- if (state === 'opted-out') optedOut.add(id);
641
- if (state === 'pending') pending.add(id);
642
- }
758
+ let suppressed = false;
759
+ let protectedReason = null;
760
+ let cadence = null;
761
+ let cadenceSetAtMs = Number.NEGATIVE_INFINITY;
762
+ sites.forEach(([suppression, optOuts, frequency], index)=>{
763
+ var _ref;
764
+ const own = index === 0;
765
+ if (suppression == null ? void 0 : suppression.exists) {
766
+ suppressed = true;
767
+ const reason = suppression.get('reason');
768
+ if (reason !== UNSUBSCRIBE_SUPPRESSION_REASON && protectedReason === null) {
769
+ protectedReason = String(reason != null ? reason : 'held');
770
+ }
771
+ }
772
+ const stored = (_ref = (optOuts == null ? void 0 : optOuts.exists) ? optOuts.get('topics') : null) != null ? _ref : {};
773
+ for (const [id, record] of Object.entries(stored)){
774
+ /*
775
+ * The shared reader, not a field test. An entry with a `resubscribedAt`
776
+ * is EVIDENCE of an opt-out that has been lifted rather than a live one
777
+ * — see `writeTopicOptOuts` for why the entry stays — and an entry with
778
+ * a `confirmedAt` carries the same shape of evidence for a
779
+ * confirmation. Only one function knows all three states.
780
+ */ const state = readTopicSubscriptionState(record);
781
+ if (state === 'opted-out') optedOut.add(id);
782
+ if (own && state === 'pending') pending.add(id);
783
+ }
784
+ // The most recent choice on any site, the link site keeping a tie — the
785
+ // rule `filterCadenceSendable` decides by.
786
+ if ((frequency == null ? void 0 : frequency.exists) && frequency.get('cadence') != null) {
787
+ const setAtMs = Number(frequency.get('cadenceSetAtMs'));
788
+ const atMs = Number.isFinite(setAtMs) ? setAtMs : 0;
789
+ if (atMs > cadenceSetAtMs) {
790
+ cadenceSetAtMs = atMs;
791
+ cadence = frequency.get('cadence');
792
+ }
793
+ }
794
+ });
643
795
  return {
644
- suppressed: !!(suppression == null ? void 0 : suppression.exists),
645
- protectedRecord: !!(suppression == null ? void 0 : suppression.exists) && suppression.get('reason') !== UNSUBSCRIBE_SUPPRESSION_REASON,
796
+ suppressed,
797
+ protectedRecord: protectedReason !== null,
798
+ protectedReason,
646
799
  optedOut,
647
800
  pending,
648
- cadence: normalizeMarketingCadence((frequency == null ? void 0 : frequency.exists) ? frequency.get('cadence') : null)
801
+ cadence: normalizeMarketingCadence(cadence)
649
802
  };
650
803
  }
651
804
  /**
@@ -739,6 +892,45 @@ const unsubscribeHandler = async (req, res)=>{
739
892
  });
740
893
  });
741
894
  }
895
+ /**
896
+ * Lifts the opt-outs the link site's consent-group SIBLINGS hold on streams
897
+ * the person has just asked for back, so a stream rejoined here is rejoined
898
+ * from the whole sender — the send paths read a sibling's opt-out as this
899
+ * site's own.
900
+ *
901
+ * Only a live opt-out is touched, and it is lifted the way
902
+ * `writeTopicOptOuts` lifts one: `resubscribedAt` stamped onto the entry,
903
+ * which stays as the evidence that the opt-out was honored while it stood. A
904
+ * sibling's pending confirmation is left alone — it is that site's question,
905
+ * not a refusal of the sender. Nothing is read or written for a site alone.
906
+ */ async function resumeTopicsAcrossGroup(firestore, group, key, topicIds) {
907
+ const siblings = consentGroupOptOutHosts(group).slice(1);
908
+ if (!siblings.length || !topicIds.length) return;
909
+ await Promise.all(siblings.map(async (id)=>{
910
+ const ref = firestore.collection('hosts').doc(id).collection(TOPIC_OPT_OUTS_SUBCOLLECTION).doc(key);
911
+ await firestore.runTransaction(async (transaction)=>{
912
+ var _existing_get;
913
+ const existing = await transaction.get(ref);
914
+ if (!existing.exists) return;
915
+ const stored = (_existing_get = existing.get('topics')) != null ? _existing_get : {};
916
+ const lifted = {};
917
+ for (const topicId of topicIds){
918
+ const previous = stored[topicId];
919
+ if (readTopicSubscriptionState(previous) !== 'opted-out') continue;
920
+ lifted[topicId] = _extends({}, previous, {
921
+ resubscribedAt: FieldValue.serverTimestamp()
922
+ });
923
+ }
924
+ if (!Object.keys(lifted).length) return;
925
+ transaction.set(ref, {
926
+ topics: _extends({}, stored, lifted),
927
+ updatedAt: FieldValue.serverTimestamp()
928
+ }, {
929
+ merge: true
930
+ });
931
+ });
932
+ }));
933
+ }
742
934
  /**
743
935
  * Reopens ONE stream for a signed-in account (AGL-3305) — this plugin's side
744
936
  * of `plugin-email-streams`, which the console asks when an account's answer
@@ -769,13 +961,15 @@ const unsubscribeHandler = async (req, res)=>{
769
961
  status: 'held',
770
962
  reason: 'unusable-address'
771
963
  };
772
- const state = await readSubscriptionState(firestore, request.hostId, key);
964
+ // The sender the stream is rejoined FROM — the site's consent group, whose
965
+ // records hold its mail as its own do.
966
+ const group = await loadConsentGroup(request.hostId);
967
+ const state = await readSubscriptionState(firestore, group, key);
773
968
  if (state.protectedRecord) {
774
- var _suppression_get;
775
- const suppression = await firestore.collection('hosts').doc(request.hostId).collection('suppressions').doc(key).get();
969
+ var _state_protectedReason;
776
970
  return {
777
971
  status: 'held',
778
- reason: String((_suppression_get = suppression.get('reason')) != null ? _suppression_get : 'held')
972
+ reason: (_state_protectedReason = state.protectedReason) != null ? _state_protectedReason : 'held'
779
973
  };
780
974
  }
781
975
  const email = String(request.email).trim().toLowerCase();
@@ -788,6 +982,9 @@ const unsubscribeHandler = async (req, res)=>{
788
982
  ],
789
983
  confirmPending: false
790
984
  });
985
+ await resumeTopicsAcrossGroup(firestore, group, key, [
986
+ request.topicId
987
+ ]);
791
988
  return {
792
989
  status: 'rejoined',
793
990
  releasedSuppression: false,
@@ -803,7 +1000,10 @@ const unsubscribeHandler = async (req, res)=>{
803
1000
  ],
804
1001
  confirmPending: false
805
1002
  });
806
- if (!await releaseSiteSuppression(firestore, request.hostId, key)) {
1003
+ await resumeTopicsAcrossGroup(firestore, group, key, [
1004
+ request.topicId
1005
+ ]);
1006
+ if (!await releaseSiteSuppression(firestore, group, key)) {
807
1007
  // Turned into a bounce or a complaint between the read and the lift.
808
1008
  return {
809
1009
  status: 'held',
@@ -849,7 +1049,7 @@ const unsubscribeHandler = async (req, res)=>{
849
1049
  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>';
850
1050
  }
851
1051
  /** The preference page's body. */ function preferencesFormBody(args) {
852
- const { email, query, topics, state, topicId, brand } = args;
1052
+ const { email, query, topics, state, topicId, brand, sender } = args;
853
1053
  const pal = brand.pal;
854
1054
  // A bounce or a complaint is not a preference, so the page does not pretend
855
1055
  // the recipient can edit their way out of one. Shown instead of the form
@@ -858,7 +1058,7 @@ const unsubscribeHandler = async (req, res)=>{
858
1058
  if (state.protectedRecord) return protectedAddressBody();
859
1059
  const current = resolveCampaignTopic(topicId, topics);
860
1060
  const action = `/api/email/preferences?${escapeHtml(query)}`;
861
- 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
1061
+ return heading('Email preferences') + paragraph(`Choose what <strong style="color:${PAL.ink}">${escapeHtml(email)}</strong> should keep receiving from ` + `<strong style="color:${PAL.ink}">${sender.name}</strong>. ` + 'Unticked emails stop; everything else carries on.', 8) + reachParagraph(sender, 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
862
1062
  // unsubscribed recipient sees every box empty — which is the state
863
1063
  // they are actually in, and the state the form must round-trip. An
864
1064
  // unconfirmed topic is empty for the same reason: the send path
@@ -883,12 +1083,13 @@ const unsubscribeHandler = async (req, res)=>{
883
1083
  }
884
1084
  /** What the result page tells the recipient actually changed. */ function changeSummary(args) {
885
1085
  const address = `<strong style="color:${PAL.ink}">${escapeHtml(args.email)}</strong>`;
1086
+ const from = args.sender.reach ? args.sender.name : 'this site';
886
1087
  if (!args.drop.length) {
887
- return `${address} keeps receiving everything this site sends.`;
1088
+ return `${address} keeps receiving everything ${from} sends.`;
888
1089
  }
889
1090
  const names = args.drop.map((topic)=>escapeHtml(topic.name)).join(', ');
890
1091
  if (!args.keep.length) {
891
- return `${address} has been unsubscribed from ${names} — everything this ` + 'site currently sends.';
1092
+ return `${address} has been unsubscribed from ${names} — everything ` + `${from} currently sends.`;
892
1093
  }
893
1094
  return `${address} will stop receiving ${names}, and keeps the rest.`;
894
1095
  }