@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/package.json +17 -17
- package/src/lib/components/email-blocks.d.ts +20 -0
- package/src/lib/components/email-blocks.js +135 -2
- package/src/lib/components/email-blocks.js.map +1 -1
- package/src/lib/components/email-design-preview.d.ts +7 -0
- package/src/lib/components/email-design-preview.js +79 -3
- package/src/lib/components/email-design-preview.js.map +1 -1
- package/src/lib/server-email-drafts.d.ts +11 -4
- package/src/lib/server-email-drafts.js +23 -4
- package/src/lib/server-email-drafts.js.map +1 -1
- package/src/lib/server-list-gate.js +8 -4
- package/src/lib/server-list-gate.js.map +1 -1
- package/src/lib/server.js +269 -68
- package/src/lib/server.js.map +1 -1
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 {
|
|
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
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
363
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
405
|
-
const
|
|
406
|
-
const
|
|
407
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
463
|
-
//
|
|
464
|
-
// waiting for.
|
|
465
|
-
const
|
|
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,
|
|
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 ' + `${
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
|
645
|
-
protectedRecord:
|
|
796
|
+
suppressed,
|
|
797
|
+
protectedRecord: protectedReason !== null,
|
|
798
|
+
protectedReason,
|
|
646
799
|
optedOut,
|
|
647
800
|
pending,
|
|
648
|
-
cadence: normalizeMarketingCadence(
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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}">${
|
|
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
|
|
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
|
|
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
|
}
|