@aglyn/plugins-marketing 1.0.0-beta.181 → 1.0.0-beta.183
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 +129 -12
- package/src/lib/server/campaign-send.js.map +1 -1
|
@@ -16,6 +16,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { checkQuota, contactMatchesSegment, isEmailTopicId, DEFAULT_CAMPAIGN_TOPIC_ID, readMarketingBasis, resolveMarketingConsentPolicy, splitByMarketingConsent, createResourceUid, decodeStoredNodes, emailStarterSendBlock, resolveBrandingProfile, visibleToHost } from "@aglyn/aglyn/server";
|
|
18
18
|
import { renderRecipientEmail } from "@aglyn/aglyn/app-utils/recipient-email-render";
|
|
19
|
+
import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-referenced-components";
|
|
19
20
|
import { assignExperimentVariant } from "../model/index.js";
|
|
20
21
|
import { campaignPlacedOnHost } from "@aglyn/shared-ui-email-campaigns/model";
|
|
21
22
|
import { readPluginRecordCard } from "@aglyn/aglyn/plugin-manager/plugin-record-cards";
|
|
@@ -61,6 +62,11 @@ import { campaignSendSiteStamp, orgCampaignSends, orgEmailCampaigns, sendIsOnHos
|
|
|
61
62
|
*/ import { claimOrgEmailSendDay, orgAgeDays, readSenderReputation, reconcileOrgEmailSendDay, recordCampaignAccepted, resolveOrgEmailRamp } from "@aglyn/tenant-data-admin/server/email-sender-reputation";
|
|
62
63
|
import { createHash } from "crypto";
|
|
63
64
|
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";
|
|
65
|
+
/*
|
|
66
|
+
* The List-Unsubscribe setting sequences share (AGL-3307), from its LEAF
|
|
67
|
+
* module: the specs that reach this file spread the real barrel under their
|
|
68
|
+
* own stubs, and a decision that turns a header off must be the real one.
|
|
69
|
+
*/ import { decideListUnsubscribe, listUnsubscribeForcedDetail, listUnsubscribeHeaders, readListUnsubscribeSetting } from "@aglyn/shared-util-email/list-unsubscribe";
|
|
64
70
|
/**
|
|
65
71
|
* Recipients one send may address.
|
|
66
72
|
*
|
|
@@ -190,6 +196,11 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
190
196
|
/**
|
|
191
197
|
* Loads a designed email template's nodes + referenced products for the
|
|
192
198
|
* render pipeline. Throws 400 when the screen isn't an email document.
|
|
199
|
+
*
|
|
200
|
+
* The nodes come back COMPOSED: every reusable block the design places — a
|
|
201
|
+
* shared header, a footer — expanded into the blocks it stands for (AGL-3287).
|
|
202
|
+
* Everything downstream renders from this one map: each recipient's copy, the
|
|
203
|
+
* test send and the composer's preview.
|
|
193
204
|
*/ async function loadEmailTemplate(hostId, screenId) {
|
|
194
205
|
var _decodeStoredNodes, _ref, _screenSnapshot_get, _screenSnapshot_get1;
|
|
195
206
|
const firestore = firebaseAdmin.app().firestore();
|
|
@@ -220,8 +231,8 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
220
231
|
* blocks missing — discovered by the recipients. Decoding first is also what
|
|
221
232
|
* makes the guard mean something: `decodeStoredNodes` returns null for an
|
|
222
233
|
* undecodable payload, so the send is refused instead of mailed empty.
|
|
223
|
-
*/ const
|
|
224
|
-
if (!Object.keys(
|
|
234
|
+
*/ const stored = (_decodeStoredNodes = decodeStoredNodes(versionSnapshot == null ? void 0 : versionSnapshot.get('nodes'))) != null ? _decodeStoredNodes : {};
|
|
235
|
+
if (!Object.keys(stored).length) {
|
|
225
236
|
throw new CampaignSendError('The email template is empty', 400);
|
|
226
237
|
}
|
|
227
238
|
/**
|
|
@@ -255,6 +266,24 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
255
266
|
});
|
|
256
267
|
if (block) throw new CampaignSendError(block.reason, 409);
|
|
257
268
|
}
|
|
269
|
+
/*
|
|
270
|
+
* THE SITE'S REUSABLE BLOCKS, EXPANDED (AGL-3287).
|
|
271
|
+
*
|
|
272
|
+
* A header or footer placed in the design is a `reusableInstance` naming one
|
|
273
|
+
* of the site's components, and the mail renderer draws a node it does not
|
|
274
|
+
* know as nothing — so without this every campaign went out without the
|
|
275
|
+
* blocks its author could see on the canvas. Grafted from the components'
|
|
276
|
+
* PUBLISHED definitions, read fresh, never from a cache: a send is not a
|
|
277
|
+
* page, and a header republished a minute ago is the one the author expects.
|
|
278
|
+
*
|
|
279
|
+
* BEFORE the product scan below, which is the ordering that matters: a
|
|
280
|
+
* product block inside a footer only exists once the footer is grafted.
|
|
281
|
+
* Each placement's property values and attribute overrides apply; its style
|
|
282
|
+
* overrides land in `sx`, which the mail renderer does not read.
|
|
283
|
+
*/ const nodes = await composeHostComponentNodes(stored, {
|
|
284
|
+
firestore,
|
|
285
|
+
hostId
|
|
286
|
+
});
|
|
258
287
|
// Resolve emailProduct references (by id — rename-safe, AGL-343).
|
|
259
288
|
const productIds = [
|
|
260
289
|
...new Set(Object.values(nodes).filter((node)=>(node == null ? void 0 : node.componentId) === 'emailProduct').map((node)=>{
|
|
@@ -293,6 +322,11 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
293
322
|
preheader: String((_screenSnapshot_get1 = screenSnapshot.get('emailPreheader')) != null ? _screenSnapshot_get1 : '')
|
|
294
323
|
};
|
|
295
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* The field on a SEND recording what it did with the List-Unsubscribe header
|
|
327
|
+
* (AGL-3307) — see {@link CampaignListUnsubscribeRecord} in
|
|
328
|
+
* `@aglyn/shared-ui-email-campaigns`, which the campaign page reads.
|
|
329
|
+
*/ const LIST_UNSUBSCRIBE_RECORD_FIELD = 'listUnsubscribe';
|
|
296
330
|
export async function performCampaignSend(options) {
|
|
297
331
|
var _ref, _options_senderId, _options_proofFor, _hostPublicOrigin, _options_experimentId;
|
|
298
332
|
var _orgForHost_org, _orgForHost_org1, _consent_get;
|
|
@@ -409,6 +443,7 @@ export async function performCampaignSend(options) {
|
|
|
409
443
|
* Batches this email has already run, so the plan below can tell the second
|
|
410
444
|
* from the twentieth. Zero on every first send.
|
|
411
445
|
*/ let batchesSoFar = 0;
|
|
446
|
+
/** Why an earlier batch of this email carried a forced header, if one did. */ let forcedBefore = null;
|
|
412
447
|
/*==========================================
|
|
413
448
|
* THE NEXT BATCH'S ADMISSION CHECKS.
|
|
414
449
|
*
|
|
@@ -453,6 +488,14 @@ export async function performCampaignSend(options) {
|
|
|
453
488
|
...settled.skipped
|
|
454
489
|
]);
|
|
455
490
|
batchesSoFar = Math.max(0, Math.floor(Number((_ref2 = (_sendSnapshot_get2 = sendSnapshot.get('resume')) == null ? void 0 : _sendSnapshot_get2.batch) != null ? _ref2 : 0)) || 0);
|
|
491
|
+
/*
|
|
492
|
+
* An earlier batch the bulk guard turned the header back on for keeps
|
|
493
|
+
* it on for the rest of the email (AGL-3307): one mailing does not lose
|
|
494
|
+
* its unsubscribe button halfway through because a day rolled over.
|
|
495
|
+
*/ const earlier = sendSnapshot.get(LIST_UNSUBSCRIBE_RECORD_FIELD);
|
|
496
|
+
if ((earlier == null ? void 0 : earlier.forced) === true && (earlier.reason === 'bulk-volume' || earlier.reason === 'pooled-identity' || earlier.reason === 'volume-unknown')) {
|
|
497
|
+
forcedBefore = earlier.reason;
|
|
498
|
+
}
|
|
456
499
|
}
|
|
457
500
|
/**
|
|
458
501
|
* A batch that found nothing left to do: the email is FINISHED, not broken.
|
|
@@ -967,6 +1010,12 @@ export async function performCampaignSend(options) {
|
|
|
967
1010
|
* every single day and never send anything at all.
|
|
968
1011
|
*/ let batchCap = MAX_RECIPIENTS_PER_SEND;
|
|
969
1012
|
/** Today's ramp, resolved once and claimed against below. */ let ramp = null;
|
|
1013
|
+
/**
|
|
1014
|
+
* Campaign messages this workspace has sent in the last day, for the
|
|
1015
|
+
* List-Unsubscribe bulk guard (AGL-3307). `null` when the window could not
|
|
1016
|
+
* be read, which the guard answers by carrying the header. Zero on a test
|
|
1017
|
+
* send, which is one message to somebody on the workspace.
|
|
1018
|
+
*/ let recentCampaignVolume = 0;
|
|
970
1019
|
if (!proofOnly) {
|
|
971
1020
|
var _orgForHost_org2, _orgForHost_org3;
|
|
972
1021
|
/** This workspace's seven-day grade, and the window both controls read. */ const reputation = await readSenderReputation({
|
|
@@ -974,6 +1023,7 @@ export async function performCampaignSend(options) {
|
|
|
974
1023
|
policy: reputationPolicy,
|
|
975
1024
|
reinstatedUntilMs: orgForHost == null ? void 0 : (_orgForHost_org2 = orgForHost.org) == null ? void 0 : _orgForHost_org2['emailReputationReinstatedUntilMs']
|
|
976
1025
|
});
|
|
1026
|
+
recentCampaignVolume = reputation.degraded ? null : reputation.window.acceptedLastTwoDays;
|
|
977
1027
|
/*
|
|
978
1028
|
* THE CIRCUIT BREAKER.
|
|
979
1029
|
*
|
|
@@ -1459,7 +1509,42 @@ export async function performCampaignSend(options) {
|
|
|
1459
1509
|
* when, not about whom, and settling a recipient on one would drop somebody
|
|
1460
1510
|
* the provider never even looked at while the campaign closed as complete.
|
|
1461
1511
|
* They break the loop into `deferred` instead; see the branch below.
|
|
1462
|
-
*/
|
|
1512
|
+
*/ /*==========================================
|
|
1513
|
+
* THE MAIL-CLIENT UNSUBSCRIBE BUTTON (AGL-3307).
|
|
1514
|
+
*
|
|
1515
|
+
* The campaign's `listUnsubscribe` setting, ON unless somebody turned it
|
|
1516
|
+
* off — and turned back on for this send when leaving it off would break
|
|
1517
|
+
* Gmail's and Yahoo's bulk-sender rule, see `decideListUnsubscribe`. The
|
|
1518
|
+
* volume is what the workspace sent in the last day plus EVERYONE this
|
|
1519
|
+
* email still has to reach, not just this batch, so an email that will
|
|
1520
|
+
* make the workspace a bulk sender carries the header from its first
|
|
1521
|
+
* message rather than from the batch that crosses the line.
|
|
1522
|
+
*
|
|
1523
|
+
* The footer link is not part of this: `renderRecipientEmail` writes it
|
|
1524
|
+
* into every message whatever the header does.
|
|
1525
|
+
*
|
|
1526
|
+
* An unreadable campaign reads as the default, ON, and an unreadable
|
|
1527
|
+
* volume forces it on: the header is the safe direction to be wrong in.
|
|
1528
|
+
*=========================================*/ let listUnsubscribeRequested = true;
|
|
1529
|
+
if (options.emailCampaignId) {
|
|
1530
|
+
try {
|
|
1531
|
+
const container = await orgEmailCampaigns(firestore, orgId).doc(options.emailCampaignId).get();
|
|
1532
|
+
listUnsubscribeRequested = readListUnsubscribeSetting(container.exists ? container.get('listUnsubscribe') : undefined, true);
|
|
1533
|
+
} catch (error) {
|
|
1534
|
+
console.error('[campaign] campaign unreadable — keeping the unsubscribe header on', error);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
const listUnsubscribe = decideListUnsubscribe({
|
|
1538
|
+
requested: listUnsubscribeRequested,
|
|
1539
|
+
recentVolume: recentCampaignVolume != null ? recentCampaignVolume : 0,
|
|
1540
|
+
sendVolume: proofOnly ? sendable.length : consentSplit.mailable.length,
|
|
1541
|
+
pooled: sendingIdentity.source === 'shared',
|
|
1542
|
+
forcedBefore: recentCampaignVolume === null ? 'volume-unknown' : forcedBefore
|
|
1543
|
+
});
|
|
1544
|
+
if (listUnsubscribe.forced) {
|
|
1545
|
+
console.warn(`[campaign] ${options.campaignId || 'new send'}: unsubscribe header ` + `forced on (${listUnsubscribe.reason}, ` + `${listUnsubscribe.volume}/${listUnsubscribe.threshold})`);
|
|
1546
|
+
}
|
|
1547
|
+
const sendableSet = new Set(sendable);
|
|
1463
1548
|
const settledOut = recipients.filter((email)=>!sendableSet.has(email));
|
|
1464
1549
|
let sent = 0;
|
|
1465
1550
|
/** Recipients a rate refusal left untouched mid-batch, if any. */ let deferred = 0;
|
|
@@ -1513,9 +1598,9 @@ export async function performCampaignSend(options) {
|
|
|
1513
1598
|
* reference `{{unsubscribeUrl}}`, and because the page it opens is still
|
|
1514
1599
|
* where you go to unsubscribe — with "Unsubscribe from everything" on
|
|
1515
1600
|
* it, one button away.
|
|
1516
|
-
*/ const oneClickUrl = buildUnsubscribeUrl(_extends({}, link, {
|
|
1601
|
+
*/ const oneClickUrl = listUnsubscribe.on ? buildUnsubscribeUrl(_extends({}, link, {
|
|
1517
1602
|
surface: 'one-click'
|
|
1518
|
-
}));
|
|
1603
|
+
})) : '';
|
|
1519
1604
|
const unsubscribeUrl = buildUnsubscribeUrl(_extends({}, link, {
|
|
1520
1605
|
surface: 'preferences'
|
|
1521
1606
|
}));
|
|
@@ -1592,10 +1677,13 @@ export async function performCampaignSend(options) {
|
|
|
1592
1677
|
// person can choose on a page; they change nothing about what a
|
|
1593
1678
|
// machine POSTing this header is promised, which is that the
|
|
1594
1679
|
// recipient stops hearing from this site.
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1680
|
+
//
|
|
1681
|
+
// Only when the campaign's setting — or the bulk guard — says so
|
|
1682
|
+
// (AGL-3307). Off, the footer link above is the way out, and the
|
|
1683
|
+
// pair is written by the builder sequences use.
|
|
1684
|
+
headers: listUnsubscribeHeaders({
|
|
1685
|
+
url: oneClickUrl
|
|
1686
|
+
}),
|
|
1599
1687
|
// The campaign's own display name where the composer set one, and the
|
|
1600
1688
|
// org's branding default otherwise. Either way the ADDRESS is the
|
|
1601
1689
|
// resolved identity's — `applyFromName` replaces the display name in
|
|
@@ -1808,6 +1896,11 @@ export async function performCampaignSend(options) {
|
|
|
1808
1896
|
* the hour or the day THREW rather than reaching here, so the only reason
|
|
1809
1897
|
* this is not immediate is a batch that ran to the end of its own cap.
|
|
1810
1898
|
*/ const nextAtMs = plan.resuming ? Date.now() : 0;
|
|
1899
|
+
const listUnsubscribeResult = {
|
|
1900
|
+
on: listUnsubscribe.on,
|
|
1901
|
+
forced: listUnsubscribe.forced,
|
|
1902
|
+
reason: listUnsubscribe.reason
|
|
1903
|
+
};
|
|
1811
1904
|
if (options.recordCampaign === false) {
|
|
1812
1905
|
return _extends({
|
|
1813
1906
|
campaignId,
|
|
@@ -1819,7 +1912,9 @@ export async function performCampaignSend(options) {
|
|
|
1819
1912
|
sent
|
|
1820
1913
|
}, deferred ? {
|
|
1821
1914
|
deferred
|
|
1822
|
-
} : {}
|
|
1915
|
+
} : {}, {
|
|
1916
|
+
listUnsubscribe: listUnsubscribeResult
|
|
1917
|
+
});
|
|
1823
1918
|
}
|
|
1824
1919
|
/*==========================================
|
|
1825
1920
|
* ADDING TO A COUNTER, AS AGAINST REPLACING IT.
|
|
@@ -1892,7 +1987,25 @@ export async function performCampaignSend(options) {
|
|
|
1892
1987
|
displayName: options.displayName
|
|
1893
1988
|
} : {}, options.emailCampaignId ? {
|
|
1894
1989
|
emailCampaignId: options.emailCampaignId
|
|
1895
|
-
} : {},
|
|
1990
|
+
} : {}, {
|
|
1991
|
+
/*
|
|
1992
|
+
* WHAT THIS EMAIL DID WITH THE UNSUBSCRIBE HEADER, and why (AGL-3307).
|
|
1993
|
+
* Written absolutely on every batch; a batch after a forced one is
|
|
1994
|
+
* forced too, so the last write is the email's answer. The campaign
|
|
1995
|
+
* page reads `forced` and `detail` to say it was turned back on.
|
|
1996
|
+
*/ [LIST_UNSUBSCRIBE_RECORD_FIELD]: _extends({
|
|
1997
|
+
requested: listUnsubscribe.requested,
|
|
1998
|
+
on: listUnsubscribe.on,
|
|
1999
|
+
forced: listUnsubscribe.forced
|
|
2000
|
+
}, listUnsubscribe.reason ? {
|
|
2001
|
+
reason: listUnsubscribe.reason,
|
|
2002
|
+
detail: listUnsubscribeForcedDetail(listUnsubscribe)
|
|
2003
|
+
} : {}, {
|
|
2004
|
+
volume: listUnsubscribe.volume,
|
|
2005
|
+
threshold: listUnsubscribe.threshold,
|
|
2006
|
+
atMs: Date.now()
|
|
2007
|
+
})
|
|
2008
|
+
}, experiment ? {
|
|
1896
2009
|
experimentId: experiment.$id
|
|
1897
2010
|
} : {}, {
|
|
1898
2011
|
stats: _extends({
|
|
@@ -1995,7 +2108,9 @@ export async function performCampaignSend(options) {
|
|
|
1995
2108
|
batch: plan.batch
|
|
1996
2109
|
}, plan.resuming ? {
|
|
1997
2110
|
nextAtMs
|
|
1998
|
-
} : {}
|
|
2111
|
+
} : {}, {
|
|
2112
|
+
listUnsubscribe: listUnsubscribeResult
|
|
2113
|
+
});
|
|
1999
2114
|
}
|
|
2000
2115
|
/**
|
|
2001
2116
|
* THE ADDRESSES A TEST SEND MAY BE DELIVERED TO.
|
|
@@ -2710,6 +2825,8 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
|
|
|
2710
2825
|
preheader
|
|
2711
2826
|
}, persona ? {
|
|
2712
2827
|
proofPersona: persona
|
|
2828
|
+
} : {}, emailCampaignId ? {
|
|
2829
|
+
emailCampaignId
|
|
2713
2830
|
} : {}, {
|
|
2714
2831
|
recordCampaign: false,
|
|
2715
2832
|
senderUid: decoded.uid,
|