@aglyn/plugins-marketing 1.0.0-beta.181 → 1.0.0-beta.182
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 +15 -15
- package/src/lib/components/campaign-composer.js +2 -0
- package/src/lib/components/campaign-composer.js.map +1 -1
- package/src/lib/components/campaign-detail-card.js +25 -2
- package/src/lib/components/campaign-detail-card.js.map +1 -1
- package/src/lib/components/campaign-edit-drawer.d.ts +3 -0
- package/src/lib/components/campaign-edit-drawer.js +27 -3
- package/src/lib/components/campaign-edit-drawer.js.map +1 -1
- package/src/lib/server/campaign-send.d.ts +7 -0
- package/src/lib/server/campaign-send.js +103 -10
- package/src/lib/server/campaign-send.js.map +1 -1
|
@@ -61,6 +61,11 @@ import { campaignSendSiteStamp, orgCampaignSends, orgEmailCampaigns, sendIsOnHos
|
|
|
61
61
|
*/ import { claimOrgEmailSendDay, orgAgeDays, readSenderReputation, reconcileOrgEmailSendDay, recordCampaignAccepted, resolveOrgEmailRamp } from "@aglyn/tenant-data-admin/server/email-sender-reputation";
|
|
62
62
|
import { createHash } from "crypto";
|
|
63
63
|
import { EMAIL_MAX_AUDIENCE_PER_SEND, EMAIL_MAX_RECIPIENTS_PER_SEND, campaignBatchPlan, createProviderRequestPacer, effectiveReputationPolicy, HOST_SENDERS_COLLECTION, isEmailConfigured, rateLimitedRetryAtMs, readHostSender, sendEmail, sendingIdentityRefusal, sentAsStamp } from "@aglyn/shared-util-email";
|
|
64
|
+
/*
|
|
65
|
+
* The List-Unsubscribe setting sequences share (AGL-3307), from its LEAF
|
|
66
|
+
* module: the specs that reach this file spread the real barrel under their
|
|
67
|
+
* own stubs, and a decision that turns a header off must be the real one.
|
|
68
|
+
*/ import { decideListUnsubscribe, listUnsubscribeForcedDetail, listUnsubscribeHeaders, readListUnsubscribeSetting } from "@aglyn/shared-util-email/list-unsubscribe";
|
|
64
69
|
/**
|
|
65
70
|
* Recipients one send may address.
|
|
66
71
|
*
|
|
@@ -293,6 +298,11 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
293
298
|
preheader: String((_screenSnapshot_get1 = screenSnapshot.get('emailPreheader')) != null ? _screenSnapshot_get1 : '')
|
|
294
299
|
};
|
|
295
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* The field on a SEND recording what it did with the List-Unsubscribe header
|
|
303
|
+
* (AGL-3307) — see {@link CampaignListUnsubscribeRecord} in
|
|
304
|
+
* `@aglyn/shared-ui-email-campaigns`, which the campaign page reads.
|
|
305
|
+
*/ const LIST_UNSUBSCRIBE_RECORD_FIELD = 'listUnsubscribe';
|
|
296
306
|
export async function performCampaignSend(options) {
|
|
297
307
|
var _ref, _options_senderId, _options_proofFor, _hostPublicOrigin, _options_experimentId;
|
|
298
308
|
var _orgForHost_org, _orgForHost_org1, _consent_get;
|
|
@@ -409,6 +419,7 @@ export async function performCampaignSend(options) {
|
|
|
409
419
|
* Batches this email has already run, so the plan below can tell the second
|
|
410
420
|
* from the twentieth. Zero on every first send.
|
|
411
421
|
*/ let batchesSoFar = 0;
|
|
422
|
+
/** Why an earlier batch of this email carried a forced header, if one did. */ let forcedBefore = null;
|
|
412
423
|
/*==========================================
|
|
413
424
|
* THE NEXT BATCH'S ADMISSION CHECKS.
|
|
414
425
|
*
|
|
@@ -453,6 +464,14 @@ export async function performCampaignSend(options) {
|
|
|
453
464
|
...settled.skipped
|
|
454
465
|
]);
|
|
455
466
|
batchesSoFar = Math.max(0, Math.floor(Number((_ref2 = (_sendSnapshot_get2 = sendSnapshot.get('resume')) == null ? void 0 : _sendSnapshot_get2.batch) != null ? _ref2 : 0)) || 0);
|
|
467
|
+
/*
|
|
468
|
+
* An earlier batch the bulk guard turned the header back on for keeps
|
|
469
|
+
* it on for the rest of the email (AGL-3307): one mailing does not lose
|
|
470
|
+
* its unsubscribe button halfway through because a day rolled over.
|
|
471
|
+
*/ const earlier = sendSnapshot.get(LIST_UNSUBSCRIBE_RECORD_FIELD);
|
|
472
|
+
if ((earlier == null ? void 0 : earlier.forced) === true && (earlier.reason === 'bulk-volume' || earlier.reason === 'pooled-identity' || earlier.reason === 'volume-unknown')) {
|
|
473
|
+
forcedBefore = earlier.reason;
|
|
474
|
+
}
|
|
456
475
|
}
|
|
457
476
|
/**
|
|
458
477
|
* A batch that found nothing left to do: the email is FINISHED, not broken.
|
|
@@ -967,6 +986,12 @@ export async function performCampaignSend(options) {
|
|
|
967
986
|
* every single day and never send anything at all.
|
|
968
987
|
*/ let batchCap = MAX_RECIPIENTS_PER_SEND;
|
|
969
988
|
/** Today's ramp, resolved once and claimed against below. */ let ramp = null;
|
|
989
|
+
/**
|
|
990
|
+
* Campaign messages this workspace has sent in the last day, for the
|
|
991
|
+
* List-Unsubscribe bulk guard (AGL-3307). `null` when the window could not
|
|
992
|
+
* be read, which the guard answers by carrying the header. Zero on a test
|
|
993
|
+
* send, which is one message to somebody on the workspace.
|
|
994
|
+
*/ let recentCampaignVolume = 0;
|
|
970
995
|
if (!proofOnly) {
|
|
971
996
|
var _orgForHost_org2, _orgForHost_org3;
|
|
972
997
|
/** This workspace's seven-day grade, and the window both controls read. */ const reputation = await readSenderReputation({
|
|
@@ -974,6 +999,7 @@ export async function performCampaignSend(options) {
|
|
|
974
999
|
policy: reputationPolicy,
|
|
975
1000
|
reinstatedUntilMs: orgForHost == null ? void 0 : (_orgForHost_org2 = orgForHost.org) == null ? void 0 : _orgForHost_org2['emailReputationReinstatedUntilMs']
|
|
976
1001
|
});
|
|
1002
|
+
recentCampaignVolume = reputation.degraded ? null : reputation.window.acceptedLastTwoDays;
|
|
977
1003
|
/*
|
|
978
1004
|
* THE CIRCUIT BREAKER.
|
|
979
1005
|
*
|
|
@@ -1459,7 +1485,42 @@ export async function performCampaignSend(options) {
|
|
|
1459
1485
|
* when, not about whom, and settling a recipient on one would drop somebody
|
|
1460
1486
|
* the provider never even looked at while the campaign closed as complete.
|
|
1461
1487
|
* They break the loop into `deferred` instead; see the branch below.
|
|
1462
|
-
*/
|
|
1488
|
+
*/ /*==========================================
|
|
1489
|
+
* THE MAIL-CLIENT UNSUBSCRIBE BUTTON (AGL-3307).
|
|
1490
|
+
*
|
|
1491
|
+
* The campaign's `listUnsubscribe` setting, ON unless somebody turned it
|
|
1492
|
+
* off — and turned back on for this send when leaving it off would break
|
|
1493
|
+
* Gmail's and Yahoo's bulk-sender rule, see `decideListUnsubscribe`. The
|
|
1494
|
+
* volume is what the workspace sent in the last day plus EVERYONE this
|
|
1495
|
+
* email still has to reach, not just this batch, so an email that will
|
|
1496
|
+
* make the workspace a bulk sender carries the header from its first
|
|
1497
|
+
* message rather than from the batch that crosses the line.
|
|
1498
|
+
*
|
|
1499
|
+
* The footer link is not part of this: `renderRecipientEmail` writes it
|
|
1500
|
+
* into every message whatever the header does.
|
|
1501
|
+
*
|
|
1502
|
+
* An unreadable campaign reads as the default, ON, and an unreadable
|
|
1503
|
+
* volume forces it on: the header is the safe direction to be wrong in.
|
|
1504
|
+
*=========================================*/ let listUnsubscribeRequested = true;
|
|
1505
|
+
if (options.emailCampaignId) {
|
|
1506
|
+
try {
|
|
1507
|
+
const container = await orgEmailCampaigns(firestore, orgId).doc(options.emailCampaignId).get();
|
|
1508
|
+
listUnsubscribeRequested = readListUnsubscribeSetting(container.exists ? container.get('listUnsubscribe') : undefined, true);
|
|
1509
|
+
} catch (error) {
|
|
1510
|
+
console.error('[campaign] campaign unreadable — keeping the unsubscribe header on', error);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
const listUnsubscribe = decideListUnsubscribe({
|
|
1514
|
+
requested: listUnsubscribeRequested,
|
|
1515
|
+
recentVolume: recentCampaignVolume != null ? recentCampaignVolume : 0,
|
|
1516
|
+
sendVolume: proofOnly ? sendable.length : consentSplit.mailable.length,
|
|
1517
|
+
pooled: sendingIdentity.source === 'shared',
|
|
1518
|
+
forcedBefore: recentCampaignVolume === null ? 'volume-unknown' : forcedBefore
|
|
1519
|
+
});
|
|
1520
|
+
if (listUnsubscribe.forced) {
|
|
1521
|
+
console.warn(`[campaign] ${options.campaignId || 'new send'}: unsubscribe header ` + `forced on (${listUnsubscribe.reason}, ` + `${listUnsubscribe.volume}/${listUnsubscribe.threshold})`);
|
|
1522
|
+
}
|
|
1523
|
+
const sendableSet = new Set(sendable);
|
|
1463
1524
|
const settledOut = recipients.filter((email)=>!sendableSet.has(email));
|
|
1464
1525
|
let sent = 0;
|
|
1465
1526
|
/** Recipients a rate refusal left untouched mid-batch, if any. */ let deferred = 0;
|
|
@@ -1513,9 +1574,9 @@ export async function performCampaignSend(options) {
|
|
|
1513
1574
|
* reference `{{unsubscribeUrl}}`, and because the page it opens is still
|
|
1514
1575
|
* where you go to unsubscribe — with "Unsubscribe from everything" on
|
|
1515
1576
|
* it, one button away.
|
|
1516
|
-
*/ const oneClickUrl = buildUnsubscribeUrl(_extends({}, link, {
|
|
1577
|
+
*/ const oneClickUrl = listUnsubscribe.on ? buildUnsubscribeUrl(_extends({}, link, {
|
|
1517
1578
|
surface: 'one-click'
|
|
1518
|
-
}));
|
|
1579
|
+
})) : '';
|
|
1519
1580
|
const unsubscribeUrl = buildUnsubscribeUrl(_extends({}, link, {
|
|
1520
1581
|
surface: 'preferences'
|
|
1521
1582
|
}));
|
|
@@ -1592,10 +1653,13 @@ export async function performCampaignSend(options) {
|
|
|
1592
1653
|
// person can choose on a page; they change nothing about what a
|
|
1593
1654
|
// machine POSTing this header is promised, which is that the
|
|
1594
1655
|
// recipient stops hearing from this site.
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1656
|
+
//
|
|
1657
|
+
// Only when the campaign's setting — or the bulk guard — says so
|
|
1658
|
+
// (AGL-3307). Off, the footer link above is the way out, and the
|
|
1659
|
+
// pair is written by the builder sequences use.
|
|
1660
|
+
headers: listUnsubscribeHeaders({
|
|
1661
|
+
url: oneClickUrl
|
|
1662
|
+
}),
|
|
1599
1663
|
// The campaign's own display name where the composer set one, and the
|
|
1600
1664
|
// org's branding default otherwise. Either way the ADDRESS is the
|
|
1601
1665
|
// resolved identity's — `applyFromName` replaces the display name in
|
|
@@ -1808,6 +1872,11 @@ export async function performCampaignSend(options) {
|
|
|
1808
1872
|
* the hour or the day THREW rather than reaching here, so the only reason
|
|
1809
1873
|
* this is not immediate is a batch that ran to the end of its own cap.
|
|
1810
1874
|
*/ const nextAtMs = plan.resuming ? Date.now() : 0;
|
|
1875
|
+
const listUnsubscribeResult = {
|
|
1876
|
+
on: listUnsubscribe.on,
|
|
1877
|
+
forced: listUnsubscribe.forced,
|
|
1878
|
+
reason: listUnsubscribe.reason
|
|
1879
|
+
};
|
|
1811
1880
|
if (options.recordCampaign === false) {
|
|
1812
1881
|
return _extends({
|
|
1813
1882
|
campaignId,
|
|
@@ -1819,7 +1888,9 @@ export async function performCampaignSend(options) {
|
|
|
1819
1888
|
sent
|
|
1820
1889
|
}, deferred ? {
|
|
1821
1890
|
deferred
|
|
1822
|
-
} : {}
|
|
1891
|
+
} : {}, {
|
|
1892
|
+
listUnsubscribe: listUnsubscribeResult
|
|
1893
|
+
});
|
|
1823
1894
|
}
|
|
1824
1895
|
/*==========================================
|
|
1825
1896
|
* ADDING TO A COUNTER, AS AGAINST REPLACING IT.
|
|
@@ -1892,7 +1963,25 @@ export async function performCampaignSend(options) {
|
|
|
1892
1963
|
displayName: options.displayName
|
|
1893
1964
|
} : {}, options.emailCampaignId ? {
|
|
1894
1965
|
emailCampaignId: options.emailCampaignId
|
|
1895
|
-
} : {},
|
|
1966
|
+
} : {}, {
|
|
1967
|
+
/*
|
|
1968
|
+
* WHAT THIS EMAIL DID WITH THE UNSUBSCRIBE HEADER, and why (AGL-3307).
|
|
1969
|
+
* Written absolutely on every batch; a batch after a forced one is
|
|
1970
|
+
* forced too, so the last write is the email's answer. The campaign
|
|
1971
|
+
* page reads `forced` and `detail` to say it was turned back on.
|
|
1972
|
+
*/ [LIST_UNSUBSCRIBE_RECORD_FIELD]: _extends({
|
|
1973
|
+
requested: listUnsubscribe.requested,
|
|
1974
|
+
on: listUnsubscribe.on,
|
|
1975
|
+
forced: listUnsubscribe.forced
|
|
1976
|
+
}, listUnsubscribe.reason ? {
|
|
1977
|
+
reason: listUnsubscribe.reason,
|
|
1978
|
+
detail: listUnsubscribeForcedDetail(listUnsubscribe)
|
|
1979
|
+
} : {}, {
|
|
1980
|
+
volume: listUnsubscribe.volume,
|
|
1981
|
+
threshold: listUnsubscribe.threshold,
|
|
1982
|
+
atMs: Date.now()
|
|
1983
|
+
})
|
|
1984
|
+
}, experiment ? {
|
|
1896
1985
|
experimentId: experiment.$id
|
|
1897
1986
|
} : {}, {
|
|
1898
1987
|
stats: _extends({
|
|
@@ -1995,7 +2084,9 @@ export async function performCampaignSend(options) {
|
|
|
1995
2084
|
batch: plan.batch
|
|
1996
2085
|
}, plan.resuming ? {
|
|
1997
2086
|
nextAtMs
|
|
1998
|
-
} : {}
|
|
2087
|
+
} : {}, {
|
|
2088
|
+
listUnsubscribe: listUnsubscribeResult
|
|
2089
|
+
});
|
|
1999
2090
|
}
|
|
2000
2091
|
/**
|
|
2001
2092
|
* THE ADDRESSES A TEST SEND MAY BE DELIVERED TO.
|
|
@@ -2710,6 +2801,8 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
|
|
|
2710
2801
|
preheader
|
|
2711
2802
|
}, persona ? {
|
|
2712
2803
|
proofPersona: persona
|
|
2804
|
+
} : {}, emailCampaignId ? {
|
|
2805
|
+
emailCampaignId
|
|
2713
2806
|
} : {}, {
|
|
2714
2807
|
recordCampaign: false,
|
|
2715
2808
|
senderUid: decoded.uid,
|