@aglyn/plugins-marketing 1.0.0-beta.200 → 1.0.0-beta.202

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/campaign-composer.js +14 -6
  3. package/src/lib/components/campaign-composer.js.map +1 -1
  4. package/src/lib/components/campaign-conversions-card.js +6 -0
  5. package/src/lib/components/campaign-conversions-card.js.map +1 -1
  6. package/src/lib/components/campaign-detail-card.js +1 -1
  7. package/src/lib/components/campaign-detail-card.js.map +1 -1
  8. package/src/lib/components/campaign-members-section.d.ts +16 -4
  9. package/src/lib/components/campaign-members-section.js +171 -105
  10. package/src/lib/components/campaign-members-section.js.map +1 -1
  11. package/src/lib/components/campaigns-card.js +19 -9
  12. package/src/lib/components/campaigns-card.js.map +1 -1
  13. package/src/lib/components/email-compose-card.js +4 -3
  14. package/src/lib/components/email-compose-card.js.map +1 -1
  15. package/src/lib/components/email-detail.js +16 -5
  16. package/src/lib/components/email-detail.js.map +1 -1
  17. package/src/lib/components/email-recipients-card.js +3 -1
  18. package/src/lib/components/email-recipients-card.js.map +1 -1
  19. package/src/lib/components/emails-list-card.js +3 -1
  20. package/src/lib/components/emails-list-card.js.map +1 -1
  21. package/src/lib/components/host-experiments-card.component.js +3 -0
  22. package/src/lib/components/host-experiments-card.component.js.map +1 -1
  23. package/src/lib/model/campaign-members-query.d.ts +37 -0
  24. package/src/lib/model/campaign-members-query.js +120 -0
  25. package/src/lib/model/campaign-members-query.js.map +1 -0
  26. package/src/lib/server/campaign-form-flags.d.ts +35 -0
  27. package/src/lib/server/campaign-form-flags.js +52 -0
  28. package/src/lib/server/campaign-form-flags.js.map +1 -0
  29. package/src/lib/server/campaign-manage.js +12 -2
  30. package/src/lib/server/campaign-manage.js.map +1 -1
  31. package/src/lib/server/campaign-send.d.ts +35 -1
  32. package/src/lib/server/campaign-send.js +230 -11
  33. package/src/lib/server/campaign-send.js.map +1 -1
  34. package/src/lib/server/email-events.js +10 -0
  35. package/src/lib/server/email-events.js.map +1 -1
@@ -14,18 +14,23 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
14
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
- */ import { checkQuota, contactMatchesSegment, isEmailTopicId, DEFAULT_CAMPAIGN_TOPIC_ID, readMarketingBasis, resolveMarketingConsentPolicy, splitByMarketingConsent, createResourceUid, decodeStoredNodes, emailStarterSendBlock, resolveBrandingProfile, visibleToHost } from "@aglyn/aglyn/server";
17
+ */ import { checkQuota, contactMatchesSegment, isEmailTopicId, DEFAULT_CAMPAIGN_TOPIC_ID, readMarketingBasis, resolveMarketingConsentPolicy, splitByMarketingConsent, createResourceUid, decodeStoredNodes, emailStarterSendBlock, resolveBrandingProfile, siteLockdownFromDocs, visibleToHost } from "@aglyn/aglyn/server";
18
18
  import { renderRecipientEmail } from "@aglyn/aglyn/app-utils/recipient-email-render";
19
19
  import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-referenced-components";
20
20
  import { assignExperimentVariant } from "../model/index.js";
21
- import { campaignPlacedOnHost } from "@aglyn/shared-ui-email-campaigns/model";
21
+ import { campaignHeldForReviewNotice, campaignPlacedOnHost, campaignSendHeldForReview } from "@aglyn/shared-ui-email-campaigns/model";
22
22
  import { readPluginRecordCard } from "@aglyn/aglyn/plugin-manager/plugin-record-cards";
23
23
  import { hostPublicOrigin } from "@aglyn/aglyn/server";
24
- import { consentGroupForSite, orgDataCollectionForHost, orgDataQueryForHost, filterSendableForHost, filterTopicSendable, firebaseAdmin, getOrgForHost, meterHostEmail, claimOrgEmailSendBudget, orgCampaignEmailSendsForMonth, readEmailSendRateConfig, readEmailSendRateWindow, reconcileCampaignSendReservation, reserveCampaignEmailSends, resolveHostSendingIdentity } from "@aglyn/tenant-data-admin";
24
+ import { consentGroupForSite, orgDataCollectionForHost, orgDataQueryForHost, filterDeliverableRecipients, filterSendableForHost, filterTopicSendable, firebaseAdmin, getOrgForHost, meterHostEmail, claimOrgEmailSendBudget, orgCampaignEmailSendsForMonth, readEmailSendRateConfig, readEmailSendRateWindow, reconcileCampaignSendReservation, reserveCampaignEmailSends, resolveHostSendingIdentity } from "@aglyn/tenant-data-admin";
25
25
  // The leaf, not the barrel: this plugin's specs substitute the barrel
26
26
  // wholesale, and the lookup must reach the real index logic under them.
27
27
  import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
28
28
  import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
29
+ /*
30
+ * The LEAF for the phishing screen's hold (AGL-3356), for the reason every
31
+ * leaf above gives: the specs that reach this file mock the barrel, and a
32
+ * control that holds a send must be the real one under every harness.
33
+ */ import { canonicalJson, HELD_SEND_AT_MS, screenOutboundSend } from "@aglyn/tenant-data-admin/server/outbound-send-review";
29
34
  /*
30
35
  * The LEAF module for the impersonation exemption too, and for a sharper
31
36
  * version of the reason the notes below give: every spec that reaches this
@@ -190,10 +195,33 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
190
195
  * Thrown ONLY before anything has been sent, so a caller that retries cannot
191
196
  * double-send.
192
197
  */ export class CampaignSendDeferredError extends CampaignSendError {
193
- constructor(message, /** When the hourly window rolls. */ retryAtMs){
194
- super(message, 429), this.retryAtMs = retryAtMs;
198
+ constructor(message, /** When the hourly window rolls. */ retryAtMs, /**
199
+ * `429` for a rate, `423` for a suspension (AGL-3356). The subclass is
200
+ * what a caller acts on; the number is only what the API answers.
201
+ */ status = 429){
202
+ super(message, status), this.retryAtMs = retryAtMs;
195
203
  }
196
204
  }
205
+ /**
206
+ * The phishing screen held this send for staff review (AGL-3356).
207
+ *
208
+ * A DEFERRAL to every caller that handles one, which is the point: the
209
+ * scheduled processor puts the row back to `scheduled` at the parked time
210
+ * rather than marking it failed, and the send route's claim is released —
211
+ * so a held email is neither lost nor mailed. `409` because the merchant's
212
+ * request conflicts with a review in progress, and the message says so in
213
+ * words they can act on.
214
+ */ export class CampaignSendHeldError extends CampaignSendDeferredError {
215
+ constructor(reference){
216
+ super('This email is being reviewed before it sends, because new workspaces ' + 'have their first emails checked for impersonation. It has been saved ' + 'and nothing has been sent or counted; it goes out automatically if ' + `it is approved. Reference ${reference}.`, HELD_SEND_AT_MS, 409), this.reference = reference;
217
+ }
218
+ }
219
+ /**
220
+ * How long a suspended site's scheduled campaign waits before it is asked
221
+ * about again (AGL-3356). An hour keeps a locked workspace's due sends from
222
+ * crowding the processor's read on every fifteen-minute beat, and is short
223
+ * enough that a lifted suspension resumes the same afternoon.
224
+ */ export const CAMPAIGN_SUSPENDED_RETRY_MS = 60 * 60 * 1000;
197
225
  /**
198
226
  * Loads a designed email template's nodes + referenced products for the
199
227
  * render pipeline. Throws 400 when the screen isn't an email document.
@@ -328,6 +356,31 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
328
356
  * (AGL-3307) — see {@link CampaignListUnsubscribeRecord} in
329
357
  * `@aglyn/shared-ui-email-campaigns`, which the campaign page reads.
330
358
  */ const LIST_UNSUBSCRIBE_RECORD_FIELD = 'listUnsubscribe';
359
+ /**
360
+ * The deliverability check over one batch (AGL-3328), against the domain the
361
+ * campaign leaves from. Fails OPEN — an unreadable check, or one this
362
+ * deployment did not install, keeps everyone — because the check prevents
363
+ * bounces and a campaign that stopped for want of it would prevent mail.
364
+ */ async function checkCampaignDeliverability(emails, identity) {
365
+ var _ref, _ref1, _ref2;
366
+ var _String_match;
367
+ const fromAddress = (_ref = (_String_match = String((_ref1 = identity == null ? void 0 : identity.from) != null ? _ref1 : '').match(/<([^>]+)>/)) == null ? void 0 : _String_match[1]) != null ? _ref : String((_ref2 = identity == null ? void 0 : identity.from) != null ? _ref2 : '');
368
+ const sendingDomain = (identity == null ? void 0 : identity.domain) || (fromAddress.includes('@') ? fromAddress.slice(fromAddress.lastIndexOf('@') + 1).trim() : null);
369
+ try {
370
+ return await filterDeliverableRecipients({
371
+ emails,
372
+ sendingDomain,
373
+ purpose: 'bulk'
374
+ });
375
+ } catch (error) {
376
+ console.error('[campaign] deliverability check unavailable — sending to everyone', error);
377
+ return {
378
+ deliverable: emails,
379
+ noMailServer: [],
380
+ gatewayHeld: []
381
+ };
382
+ }
383
+ }
331
384
  export async function performCampaignSend(options) {
332
385
  var _ref, _options_senderId, _options_proofFor, _hostPublicOrigin, _options_experimentId;
333
386
  var _orgForHost_org, _orgForHost_org1, _consent_get;
@@ -429,6 +482,31 @@ export async function performCampaignSend(options) {
429
482
  if (!orgId) {
430
483
  throw new CampaignSendError('This site is not part of an organization, so it cannot send campaigns.', 409);
431
484
  }
485
+ /*
486
+ * A SUSPENDED SITE SENDS NO CAMPAIGN (AGL-3356).
487
+ *
488
+ * The console dispatcher refuses a locked caller, but the scheduled
489
+ * processor and a resumed batch arrive with no caller at all, so the
490
+ * suspension is asked here, off the two documents this send already holds
491
+ * — no read is added. The incident behind this was a workspace locked for
492
+ * phishing whose deferred batches were still queued to go.
493
+ *
494
+ * A deferral rather than a failure: a suspension is a pause, and lifting
495
+ * it must leave the campaign where it was. Thrown before anything is
496
+ * claimed, counted or sent, so the scheduled processor puts the row back
497
+ * to `scheduled` and asks again after the delay. Any active lock pauses a
498
+ * campaign, a read-only maintenance window included, because a campaign is
499
+ * the one sender that can always wait.
500
+ */ {
501
+ const nowMs = Date.now();
502
+ const lock = siteLockdownFromDocs({
503
+ org: orgForHost == null ? void 0 : orgForHost.org,
504
+ host: hostSnapshot.data()
505
+ }, nowMs);
506
+ if (lock) {
507
+ throw new CampaignSendDeferredError(lock.scope === 'org' ? 'This workspace is suspended, so its email is paused. Nothing ' + 'has been sent or counted; it resumes when the suspension is lifted.' : 'This site is suspended, so its email is paused. Nothing has ' + 'been sent or counted; it resumes when the suspension is lifted.', nowMs + CAMPAIGN_SUSPENDED_RETRY_MS, 423);
508
+ }
509
+ }
432
510
  const sends = orgCampaignSends(firestore, orgId);
433
511
  /*==========================================
434
512
  * THE FOLLOW-UP'S ADMISSION CHECKS.
@@ -1133,11 +1211,26 @@ export async function performCampaignSend(options) {
1133
1211
  * Fails OPEN on an unreadable counter — see `filterCadenceSendable`. A
1134
1212
  * pace is not a stop, and the two suppression lists above have already
1135
1213
  * removed everybody who asked us to stop entirely.
1136
- */ const sendable = await filterCadenceSendable(hostId, onTopic, {
1214
+ */ const paced = await filterCadenceSendable(hostId, onTopic, {
1137
1215
  firestore,
1138
1216
  group: consentGroup
1139
1217
  });
1140
1218
  /*
1219
+ * THE FIFTH FILTER: who this send would bounce off (AGL-3328).
1220
+ *
1221
+ * An address whose domain has no mail server is a hard bounce charged to
1222
+ * the sending domain, and an address behind a gateway that refused this
1223
+ * sending domain twice in thirty days is the third refusal in a row —
1224
+ * each one making the next more likely, at that company and at every
1225
+ * other behind the same gateway. `sendEmail` refuses both one message at
1226
+ * a time as well; subtracting them HERE is what makes the count on screen
1227
+ * true before Send, the reason the cadence filter above sits here too.
1228
+ *
1229
+ * Fails OPEN: an audience that could not be checked is mailed as it was
1230
+ * before the check existed.
1231
+ */ const deliverability = await checkCampaignDeliverability(paced, sendingIdentity);
1232
+ const sendable = deliverability.deliverable;
1233
+ /*
1141
1234
  * NOBODY IN THIS BATCH, BUT SOMEBODY AFTER IT.
1142
1235
  *
1143
1236
  * A send whose whole audience is suppressed is a 400 a merchant needs to
@@ -1151,12 +1244,13 @@ export async function performCampaignSend(options) {
1151
1244
  */ if (!sendable.length && consentSplit.mailable.length <= recipients.length) {
1152
1245
  if (options.continuation) return finishContinuation();
1153
1246
  /*
1154
- * Which of the four filters emptied the batch, because the answer changes
1247
+ * Which of the five filters emptied the batch, because the answer changes
1155
1248
  * what the merchant should do. An audience that unsubscribed is one they
1156
1249
  * have to rebuild; an audience holding for its own cadence is one that
1157
1250
  * becomes mailable on its own, and telling them it "unsubscribed" would
1158
- * send them looking for a problem that is not there.
1159
- */ throw new CampaignSendError(onTopic.length ? 'Every recipient has asked this site for mail less often than this' : 'Every recipient has unsubscribed or been suppressed', 400);
1251
+ * send them looking for a problem that is not there. An audience the
1252
+ * deliverability check emptied has addresses that would bounce.
1253
+ */ throw new CampaignSendError(paced.length ? 'Every recipient would bounce: their domains have no mail server, ' + 'or their mail gateway refused this sender twice this month' : onTopic.length ? 'Every recipient has asked this site for mail less often than this' : 'Every recipient has unsubscribed or been suppressed', 400);
1160
1254
  }
1161
1255
  // Monthly cap by the owning org's plan (dark-launch rule, AGL-238).
1162
1256
  //
@@ -1236,7 +1330,12 @@ export async function performCampaignSend(options) {
1236
1330
  * not unsubscribe, and reporting them under a heading that says they
1237
1331
  * did is the netting this readout has refused everywhere else.
1238
1332
  */ suppressed: recipients.length - onTopic.length,
1239
- cadenceHeld: onTopic.length - sendable.length,
1333
+ cadenceHeld: onTopic.length - paced.length,
1334
+ // Who the deliverability check took out, by why (AGL-3328): "N
1335
+ // recipients excluded: no mail server · M behind a gateway that
1336
+ // refused this sender".
1337
+ noMailServer: deliverability.noMailServer.length,
1338
+ gatewayHeld: deliverability.gatewayHeld.length,
1240
1339
  /*
1241
1340
  * What this send will NOT reach on its first pass, so the composer can
1242
1341
  * say "3,000 people, 500 in the first batch, the rest over the next few
@@ -1333,6 +1432,107 @@ export async function performCampaignSend(options) {
1333
1432
  }, data);
1334
1433
  }
1335
1434
  /*
1435
+ * THE PHISHING SCREEN, for a workspace in its first fortnight (AGL-3356).
1436
+ *
1437
+ * Asked once the copy is final — the design loaded, the experiment's
1438
+ * variants known — and before anything is claimed, counted or sent, so a
1439
+ * hold costs the workspace nothing but the wait. See
1440
+ * `outbound-send-review.ts` for who is screened and what a hold is, and
1441
+ * `outbound-phishing-screen.ts` for what holds.
1442
+ *
1443
+ * A follow-up is not screened: it mails the copy its first send already
1444
+ * carried, which was. A continuation is: its first batch may predate the
1445
+ * screen, and a hold there parks the rest of the audience rather than
1446
+ * mailing it.
1447
+ *
1448
+ * A HOLD PARKS THE SEND, it does not drop it. The record is written as a
1449
+ * scheduled send whose time no processor run reaches, carrying the copy
1450
+ * `storedSendOptionsFrom` reads back, and the review row in the abuse
1451
+ * queue names it. Staff dismissing the row releases it — the send goes
1452
+ * back on the clock and this screen finds it released — and actioning the
1453
+ * row cancels it.
1454
+ */ if (!options.followUp) {
1455
+ var _ref4, _hostSnapshot_data, _options_fromName, _sendingIdentity_from, _options_replyTo, _options_preheader, _options_plainText, _experiment_variants;
1456
+ const sendRef = sends.doc(campaignId);
1457
+ const screened = await screenOutboundSend({
1458
+ kind: 'campaign',
1459
+ path: sendRef.path,
1460
+ hostId,
1461
+ orgId,
1462
+ org: (_ref4 = orgForHost == null ? void 0 : orgForHost.org) != null ? _ref4 : null,
1463
+ host: (_hostSnapshot_data = hostSnapshot.data()) != null ? _hostSnapshot_data : null,
1464
+ subject,
1465
+ fromName: (_options_fromName = options.fromName) != null ? _options_fromName : null,
1466
+ // The verified address it will leave on: a lookalike domain there
1467
+ // holds for every workspace (AGL-3362).
1468
+ fromAddress: (_sendingIdentity_from = sendingIdentity.from) != null ? _sendingIdentity_from : null,
1469
+ replyTo: (_options_replyTo = options.replyTo) != null ? _options_replyTo : null,
1470
+ preheader: (_options_preheader = options.preheader) != null ? _options_preheader : null,
1471
+ bodies: [
1472
+ body,
1473
+ (_options_plainText = options.plainText) != null ? _options_plainText : '',
1474
+ template ? canonicalJson(template) : '',
1475
+ experiment ? canonicalJson((_experiment_variants = experiment.variants) != null ? _experiment_variants : null) : ''
1476
+ ]
1477
+ });
1478
+ if (screened.outcome === 'rejected') {
1479
+ throw new CampaignSendError('This email was reviewed and will not be sent. Contact support if ' + 'you believe this is a mistake.', 403);
1480
+ }
1481
+ if (screened.outcome === 'held') {
1482
+ var _options_emails;
1483
+ await sendRef.set(_extends({}, campaignSendSiteStamp(hostId), options.continuation ? {} : _extends({
1484
+ subject
1485
+ }, campaignSendSearchFields(subject), {
1486
+ body,
1487
+ audience
1488
+ }, mintsRecord ? {
1489
+ createdAtMs: Date.now()
1490
+ } : {}, options.segmentId ? {
1491
+ segmentId: options.segmentId
1492
+ } : {}, options.listId ? {
1493
+ listId: options.listId
1494
+ } : {}, options.topicId ? {
1495
+ topicId: options.topicId
1496
+ } : {}, ((_options_emails = options.emails) == null ? void 0 : _options_emails.length) ? {
1497
+ emails: options.emails.map(String).slice(0, 500)
1498
+ } : {}, experimentId ? {
1499
+ experimentId
1500
+ } : {}, options.templateScreenId ? {
1501
+ templateScreenId: options.templateScreenId
1502
+ } : {}, options.plainText ? {
1503
+ plainText: options.plainText
1504
+ } : {}, options.fromName ? {
1505
+ fromName: options.fromName
1506
+ } : {}, options.replyTo ? {
1507
+ replyTo: options.replyTo
1508
+ } : {}, options.senderId ? {
1509
+ senderId: options.senderId
1510
+ } : {}, options.preheader ? {
1511
+ preheader: options.preheader
1512
+ } : {}, options.displayName ? {
1513
+ displayName: options.displayName
1514
+ } : {}, options.emailCampaignId ? {
1515
+ emailCampaignId: options.emailCampaignId
1516
+ } : mintsRecord ? {
1517
+ emailCampaignId: null
1518
+ } : {}, {
1519
+ scheduledBy: options.senderUid
1520
+ }), {
1521
+ status: 'scheduled',
1522
+ sendAtMs: HELD_SEND_AT_MS,
1523
+ staffReview: {
1524
+ reviewId: screened.reviewId,
1525
+ reference: screened.reference,
1526
+ state: 'held',
1527
+ heldAtMs: Date.now()
1528
+ }
1529
+ }), {
1530
+ merge: true
1531
+ });
1532
+ throw new CampaignSendHeldError(screened.reference);
1533
+ }
1534
+ }
1535
+ /*
1336
1536
  * PLATFORM SEND-RATE ADMISSION CONTROL (AGL-2409).
1337
1537
  *
1338
1538
  * `sendEmail` governs every message individually and is the hard ceiling.
@@ -2039,7 +2239,9 @@ export async function performCampaignSend(options) {
2039
2239
  consentWithheld: additive(consentSplit.withheld)
2040
2240
  } : {}, {
2041
2241
  suppressed: additive(recipients.length - onTopic.length),
2042
- cadenceHeld: additive(onTopic.length - sendable.length),
2242
+ cadenceHeld: additive(onTopic.length - paced.length),
2243
+ noMailServer: additive(deliverability.noMailServer.length),
2244
+ gatewayHeld: additive(deliverability.gatewayHeld.length),
2043
2245
  /*
2044
2246
  * That this send's links were trackable at all.
2045
2247
  *
@@ -3040,6 +3242,17 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3040
3242
  * see the write below — which is console-only text that reached no
3041
3243
  * recipient and therefore contradicts no delivered mail.
3042
3244
  *=========================================*/ const rewritable = !targetSnapshot.exists || targetState === 'draft' || targetState === 'scheduled';
3245
+ /*
3246
+ * A SEND HELD FOR STAFF REVIEW IS NOT THE MERCHANT'S TO REWRITE
3247
+ * (AGL-3356). Scheduling it would move `sendAtMs` off the parked time
3248
+ * and put it on the clock without a decision, and a rewrite of its
3249
+ * copy would reach staff as content they never reviewed. The name is
3250
+ * still theirs (`update`), and so is canceling it.
3251
+ */ if (action !== 'update' && campaignSendHeldForReview(targetSnapshot.data())) {
3252
+ return res.status(409).json({
3253
+ error: campaignHeldForReviewNotice(targetSnapshot.data())
3254
+ });
3255
+ }
3043
3256
  if (action !== 'update' && !rewritable) {
3044
3257
  return res.status(409).json({
3045
3258
  error: targetState === 'sent' ? 'This email has already been sent, so its message and ' + 'audience can no longer be changed. Compose a new email.' : 'This email was canceled, so it can no longer be scheduled. ' + 'Compose a new email.'
@@ -3210,6 +3423,12 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3210
3423
  });
3211
3424
  }
3212
3425
  const sendNowState = String((_sendNowSnapshot_get = sendNowSnapshot.get('status')) != null ? _sendNowSnapshot_get : '');
3426
+ // Held for staff review (AGL-3356): "send now" is not a release.
3427
+ if (campaignSendHeldForReview(sendNowSnapshot.data())) {
3428
+ return res.status(409).json({
3429
+ error: campaignHeldForReviewNotice(sendNowSnapshot.data())
3430
+ });
3431
+ }
3213
3432
  /*
3214
3433
  * Only an email that has not gone out yet. A sent one would be mailed a
3215
3434
  * second time to the whole audience under the same id — which is what