@aglyn/plugins-marketing 1.0.0-beta.172 → 1.0.0-beta.174

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 (79) hide show
  1. package/README.md +2 -1
  2. package/package.json +15 -15
  3. package/src/lib/components/campaign-detail-card.d.ts +12 -1
  4. package/src/lib/components/campaign-detail-card.js +146 -35
  5. package/src/lib/components/campaign-detail-card.js.map +1 -1
  6. package/src/lib/components/campaign-edit-drawer.d.ts +13 -0
  7. package/src/lib/components/campaign-edit-drawer.js +56 -4
  8. package/src/lib/components/campaign-edit-drawer.js.map +1 -1
  9. package/src/lib/components/campaign-glance-card.component.js +10 -2
  10. package/src/lib/components/campaign-glance-card.component.js.map +1 -1
  11. package/src/lib/components/campaign-members-section.d.ts +6 -0
  12. package/src/lib/components/campaign-members-section.js +2 -2
  13. package/src/lib/components/campaign-members-section.js.map +1 -1
  14. package/src/lib/components/campaign-queries.d.ts +58 -0
  15. package/src/lib/components/campaign-queries.js +72 -0
  16. package/src/lib/components/campaign-queries.js.map +1 -0
  17. package/src/lib/components/campaign-reach-sections.d.ts +21 -7
  18. package/src/lib/components/campaign-reach-sections.js +20 -17
  19. package/src/lib/components/campaign-reach-sections.js.map +1 -1
  20. package/src/lib/components/campaign-report-card.d.ts +8 -1
  21. package/src/lib/components/campaign-report-card.js +33 -16
  22. package/src/lib/components/campaign-report-card.js.map +1 -1
  23. package/src/lib/components/campaigns-card.d.ts +10 -1
  24. package/src/lib/components/campaigns-card.js +102 -19
  25. package/src/lib/components/campaigns-card.js.map +1 -1
  26. package/src/lib/components/email-compose-card.d.ts +3 -2
  27. package/src/lib/components/email-compose-card.js +18 -7
  28. package/src/lib/components/email-compose-card.js.map +1 -1
  29. package/src/lib/components/email-detail.d.ts +11 -2
  30. package/src/lib/components/email-detail.js +50 -23
  31. package/src/lib/components/email-detail.js.map +1 -1
  32. package/src/lib/components/emails-list-card.d.ts +10 -1
  33. package/src/lib/components/emails-list-card.js +129 -23
  34. package/src/lib/components/emails-list-card.js.map +1 -1
  35. package/src/lib/components/host-marketing-summary-card.component.js +9 -1
  36. package/src/lib/components/host-marketing-summary-card.component.js.map +1 -1
  37. package/src/lib/components/marketing-console-page.js +81 -4
  38. package/src/lib/components/marketing-console-page.js.map +1 -1
  39. package/src/lib/components/marketing-console-sections.d.ts +16 -1
  40. package/src/lib/components/marketing-console-sections.js +25 -2
  41. package/src/lib/components/marketing-console-sections.js.map +1 -1
  42. package/src/lib/components/marketing-org-mount.d.ts +71 -0
  43. package/src/lib/components/marketing-org-mount.js +84 -0
  44. package/src/lib/components/marketing-org-mount.js.map +1 -0
  45. package/src/lib/components/use-campaign-send-api.d.ts +2 -2
  46. package/src/lib/components/use-campaign-send-api.js +22 -6
  47. package/src/lib/components/use-campaign-send-api.js.map +1 -1
  48. package/src/lib/components/use-emails-hub-path.d.ts +13 -3
  49. package/src/lib/components/use-emails-hub-path.js +25 -4
  50. package/src/lib/components/use-emails-hub-path.js.map +1 -1
  51. package/src/lib/components/use-marketing-hub-path.d.ts +19 -16
  52. package/src/lib/components/use-marketing-hub-path.js +6 -32
  53. package/src/lib/components/use-marketing-hub-path.js.map +1 -1
  54. package/src/lib/model/marketing-record-routes.js +11 -4
  55. package/src/lib/model/marketing-record-routes.js.map +1 -1
  56. package/src/lib/plugin.js +31 -1
  57. package/src/lib/plugin.js.map +1 -1
  58. package/src/lib/server/campaign-manage.d.ts +31 -2
  59. package/src/lib/server/campaign-manage.js +365 -137
  60. package/src/lib/server/campaign-manage.js.map +1 -1
  61. package/src/lib/server/campaign-org-refs.d.ts +45 -0
  62. package/src/lib/server/campaign-org-refs.js +83 -0
  63. package/src/lib/server/campaign-org-refs.js.map +1 -0
  64. package/src/lib/server/campaign-process-scheduled.d.ts +32 -2
  65. package/src/lib/server/campaign-process-scheduled.js +79 -9
  66. package/src/lib/server/campaign-process-scheduled.js.map +1 -1
  67. package/src/lib/server/campaign-recipients.js +18 -4
  68. package/src/lib/server/campaign-recipients.js.map +1 -1
  69. package/src/lib/server/campaign-send.js +139 -55
  70. package/src/lib/server/campaign-send.js.map +1 -1
  71. package/src/lib/server/email-campaign-reach.d.ts +6 -5
  72. package/src/lib/server/email-campaign-reach.js +14 -14
  73. package/src/lib/server/email-campaign-reach.js.map +1 -1
  74. package/src/lib/server/email-events.js +48 -17
  75. package/src/lib/server/email-events.js.map +1 -1
  76. package/src/lib/server/marketing-figures.js +12 -1
  77. package/src/lib/server/marketing-figures.js.map +1 -1
  78. package/src/lib/server.js +7 -3
  79. package/src/lib/server.js.map +1 -1
@@ -17,6 +17,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
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
19
  import { assignExperimentVariant } from "../model/index.js";
20
+ import { campaignPlacedOnHost } from "@aglyn/shared-ui-email-campaigns/model";
20
21
  import { readPluginRecordCard } from "@aglyn/aglyn/plugin-manager/plugin-record-cards";
21
22
  import { hostPublicOrigin } from "@aglyn/aglyn/server";
22
23
  import { consentGroupForSite, orgDataCollectionForHost, orgDataQueryForHost, filterSendableForHost, filterTopicSendable, firebaseAdmin, getOrgForHost, meterHostEmail, claimOrgEmailSendBudget, orgCampaignEmailSendsForMonth, readEmailSendRateConfig, readEmailSendRateWindow, reconcileCampaignSendReservation, reserveCampaignEmailSends, resolveHostSendingIdentity } from "@aglyn/tenant-data-admin";
@@ -48,6 +49,7 @@ import { filterCadenceSendable, recordMarketingSends } from "@aglyn/tenant-data-
48
49
  * that decides who has already had this email — is the one that must come
49
50
  * from the real module.
50
51
  */ import { CAMPAIGN_REACH_CEILING, campaignReachCovers, campaignSettledSize, partitionByCampaignReach, readCampaignReach, readCampaignSettled, recordCampaignReach, recordCampaignSkipped } from "./email-campaign-reach.js";
52
+ import { campaignSendSiteStamp, orgCampaignSends, orgEmailCampaigns, sendIsOnHost } from "./campaign-org-refs.js";
51
53
  /*
52
54
  * The LEAF module for the reputation controls too, and for the same reason
53
55
  * as the three above it: every spec that reaches this file mocks the
@@ -69,6 +71,7 @@ import { EMAIL_MAX_AUDIENCE_PER_SEND, EMAIL_MAX_RECIPIENTS_PER_SEND, campaignBat
69
71
  * nobody could check against the other two.
70
72
  */ const MAX_RECIPIENTS_PER_SEND = EMAIL_MAX_RECIPIENTS_PER_SEND;
71
73
  const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
74
+ /** What Resend accepts as a tag value; anything else fails the message. */ const RESEND_TAG_VALUE = /^[A-Za-z0-9_-]{1,256}$/;
72
75
  /** How many audience documents one Firestore round trip fetches. */ const AUDIENCE_PAGE_SIZE = 500;
73
76
  /**
74
77
  * The read budget for resolving one audience — a ceiling on the SCAN, not a
@@ -314,8 +317,10 @@ export async function performCampaignSend(options) {
314
317
  // `hostId` is deliberately NOT guarded here, and that is measured rather
315
318
  // than overlooked: both callers prove it first — the handler resolves the
316
319
  // host document and checks the caller's role on it before calling in, and
317
- // the scheduled processor passes `hostRef.id` off a document it just read. A
318
- // guard here could not fail today. A third caller would need to earn that.
320
+ // the scheduled processor passes the site a send records only after
321
+ // `scheduledSendSite` has refused an empty or path-shaped one. The host
322
+ // read below then refuses a site that does not exist. A guard here could
323
+ // not fail today. A third caller would need to earn that.
319
324
  for (const [name, value] of [
320
325
  [
321
326
  'campaignId',
@@ -372,6 +377,24 @@ export async function performCampaignSend(options) {
372
377
  if (!hostSnapshot.exists) {
373
378
  throw new CampaignSendError('Unknown site', 404);
374
379
  }
380
+ /*
381
+ * THE ORGANIZATION, resolved before anything else is read.
382
+ *
383
+ * Every send is recorded under it (`orgs/{orgId}/campaigns`), so no branch
384
+ * below — the follow-up's checks, the batch's, the final write — can
385
+ * address the send without it. The consent policy, the plan cap, the
386
+ * reputation ramp and the branding further down read the same document,
387
+ * so this is the one read of it.
388
+ *
389
+ * A site with no organization cannot send: there is nowhere to record the
390
+ * send and no plan to charge it to. `getOrgForHost` failing is treated the
391
+ * same way — refused, with nothing written.
392
+ */ const orgForHost = await getOrgForHost(hostId).catch(()=>null);
393
+ const orgId = String((_ref = orgForHost == null ? void 0 : orgForHost.orgId) != null ? _ref : '');
394
+ if (!orgId) {
395
+ throw new CampaignSendError('This site is not part of an organization, so it cannot send campaigns.', 409);
396
+ }
397
+ const sends = orgCampaignSends(firestore, orgId);
375
398
  /*==========================================
376
399
  * THE FOLLOW-UP'S ADMISSION CHECKS.
377
400
  *
@@ -399,8 +422,8 @@ export async function performCampaignSend(options) {
399
422
  if (!options.campaignId) {
400
423
  throw new CampaignSendError('A batch has to name the email it is continuing', 400);
401
424
  }
402
- const sendSnapshot = await hostRef.collection('campaigns').doc(options.campaignId).get();
403
- if (!sendSnapshot.exists) {
425
+ const sendSnapshot = await sends.doc(options.campaignId).get();
426
+ if (!sendSnapshot.exists || !sendIsOnHost(sendSnapshot, hostId)) {
404
427
  throw new CampaignSendError('Unknown email', 404);
405
428
  }
406
429
  /*
@@ -417,7 +440,7 @@ export async function performCampaignSend(options) {
417
440
  * email has already mailed is the only thing standing between a resumed
418
441
  * send and a second copy in somebody's inbox, and a batch that cannot
419
442
  * read it must not run.
420
- */ const settled = await readCampaignSettled(hostId, options.campaignId, firestore);
443
+ */ const settled = await readCampaignSettled(sends.doc(options.campaignId));
421
444
  const sentSoFar = Number((_ref1 = (_sendSnapshot_get1 = sendSnapshot.get('stats')) == null ? void 0 : _sendSnapshot_get1.sent) != null ? _ref1 : 0);
422
445
  if (!campaignReachCovers(settled.reached, sentSoFar)) {
423
446
  throw new CampaignSendError('This email does not have a complete record of who it reached, so ' + 'the rest of it cannot be sent without risking a second copy for ' + 'somebody who already has it.', 409);
@@ -446,7 +469,7 @@ export async function performCampaignSend(options) {
446
469
  */ const finishContinuation = async ()=>{
447
470
  var _options_campaignId;
448
471
  const sendId = String((_options_campaignId = options.campaignId) != null ? _options_campaignId : '');
449
- await hostRef.collection('campaigns').doc(sendId).set({
472
+ await sends.doc(sendId).set(_extends({}, campaignSendSiteStamp(hostId), {
450
473
  status: 'sent',
451
474
  resume: {
452
475
  remaining: 0,
@@ -454,7 +477,7 @@ export async function performCampaignSend(options) {
454
477
  nextAtMs: 0
455
478
  },
456
479
  lastSentAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
457
- }, {
480
+ }), {
458
481
  merge: true
459
482
  });
460
483
  return {
@@ -473,8 +496,8 @@ export async function performCampaignSend(options) {
473
496
  if (!options.campaignId) {
474
497
  throw new CampaignSendError('A follow-up has to name the email it is adding to', 400);
475
498
  }
476
- const sendSnapshot = await hostRef.collection('campaigns').doc(options.campaignId).get();
477
- if (!sendSnapshot.exists) {
499
+ const sendSnapshot = await sends.doc(options.campaignId).get();
500
+ if (!sendSnapshot.exists || !sendIsOnHost(sendSnapshot, hostId)) {
478
501
  throw new CampaignSendError('Unknown email', 404);
479
502
  }
480
503
  /*
@@ -489,7 +512,7 @@ export async function performCampaignSend(options) {
489
512
  * "nobody", and that throw is deliberately not caught: the whole feature
490
513
  * rests on being able to name who already has this email, and a send that
491
514
  * cannot is a send that must not happen.
492
- */ const keys = await readCampaignReach(hostId, options.campaignId, firestore);
515
+ */ const keys = await readCampaignReach(sends.doc(options.campaignId));
493
516
  const sentSoFar = Number((_ref3 = (_sendSnapshot_get3 = sendSnapshot.get('stats')) == null ? void 0 : _sendSnapshot_get3.sent) != null ? _ref3 : 0);
494
517
  if (!campaignReachCovers(keys, sentSoFar)) {
495
518
  throw new CampaignSendError('This email does not have a complete record of who it reached, so ' + 'it cannot be sent to more people without risking a second copy ' + 'for somebody who already has it. Compose a new email to the ' + 'people you want to add.', 409);
@@ -758,12 +781,9 @@ export async function performCampaignSend(options) {
758
781
  * decision informed — it rides the same readout as `audienceSize`, so a
759
782
  * merchant sees which population is which before sending.
760
783
  *
761
- * The org is resolved here rather than at the quota block below because the
762
- * policy lives on it and this is the first thing that needs it; the quota
763
- * lines further down reuse the same read rather than taking a second one.
764
- */ const orgForHost = await getOrgForHost(hostId).catch(()=>null);
765
- const orgId = String((_ref = orgForHost == null ? void 0 : orgForHost.orgId) != null ? _ref : '');
766
- /*
784
+ * The policy lives on the org document resolved at the top of this
785
+ * function; the quota lines further down reuse the same read.
786
+ */ /*
767
787
  * THE SENDING IDENTITY, and the refusal when it is not usable.
768
788
  *
769
789
  * Resolved ABOVE the dry run on purpose. `preview` is where a merchant finds
@@ -1603,6 +1623,18 @@ export async function performCampaignSend(options) {
1603
1623
  name: 'campaignId',
1604
1624
  value: campaignId
1605
1625
  },
1626
+ /*
1627
+ * The org the send is recorded under, so the webhook finds it
1628
+ * without a site lookup. Only a value Resend accepts is tagged —
1629
+ * ASCII letters, digits, `_` and `-`, which every generated org id
1630
+ * is — because a rejected tag fails the whole message; the webhook
1631
+ * resolves the org from the site when the tag is missing.
1632
+ */ ...RESEND_TAG_VALUE.test(orgId) ? [
1633
+ {
1634
+ name: 'orgId',
1635
+ value: orgId
1636
+ }
1637
+ ] : [],
1606
1638
  ...experiment ? [
1607
1639
  {
1608
1640
  name: 'experimentId',
@@ -1716,7 +1748,7 @@ export async function performCampaignSend(options) {
1716
1748
  * has to. The other order would leave the record short and refuse the
1717
1749
  * follow-up, which is also safe but loses the feature over a transient.
1718
1750
  */ if (options.recordCampaign !== false) {
1719
- await recordCampaignReach(hostId, campaignId, reached, firestore);
1751
+ await recordCampaignReach(sends.doc(campaignId), reached);
1720
1752
  /*
1721
1753
  * And who it decided NOT to mail, under a field of its own.
1722
1754
  *
@@ -1725,7 +1757,7 @@ export async function performCampaignSend(options) {
1725
1757
  * anybody asks for one, and the batch that asks is the NEXT invocation of
1726
1758
  * this function, which will have no way to know that these five hundred
1727
1759
  * addresses were already considered.
1728
- */ await recordCampaignSkipped(hostId, campaignId, settledOut, firestore);
1760
+ */ await recordCampaignSkipped(sends.doc(campaignId), settledOut);
1729
1761
  }
1730
1762
  // Sends are the email variant's exposures (AGL-255).
1731
1763
  if (experiment && experiment.status === 'running') {
@@ -1822,7 +1854,7 @@ export async function performCampaignSend(options) {
1822
1854
  * stand, which is the same "recorded, not recomputed" rule the populations
1823
1855
  * below are stored under: they are true of the send that happened.
1824
1856
  */ const measuresTheAudience = !options.continuation;
1825
- await hostRef.collection('campaigns').doc(campaignId).set(_extends({
1857
+ await sends.doc(campaignId).set(_extends({}, campaignSendSiteStamp(hostId), {
1826
1858
  subject,
1827
1859
  body,
1828
1860
  audience
@@ -2490,7 +2522,7 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2490
2522
  error: 'Unauthenticated'
2491
2523
  });
2492
2524
  try {
2493
- var _hostSnapshot_get, _ref11, _ref12, _ref13, _ref14;
2525
+ var _hostSnapshot_get, _ref11, _ref12, _ref13, _ref14, _ref15;
2494
2526
  var _req_body15, _req_body16, _req_body17, _req_body18, _req_body19;
2495
2527
  const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
2496
2528
  /*
@@ -2538,6 +2570,45 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2538
2570
  error: 'Not a site admin or editor'
2539
2571
  });
2540
2572
  }
2573
+ /*==========================================
2574
+ * THE ORGANIZATION'S SENDS, AND WHICH OF THEM THIS SITE MAY TOUCH.
2575
+ *
2576
+ * Sends and campaigns are recorded under the org, so every branch below
2577
+ * that reads or writes one goes through `orgSends`. It is null for a site
2578
+ * with no organization, and each such branch refuses rather than
2579
+ * guessing where to write.
2580
+ *
2581
+ * The role above is a role on THIS site, while the collection holds every
2582
+ * site's mail. So a send addressed by id is acted on only when it is sent
2583
+ * as this site (`sendIsOnHost`); a sibling site's send answers exactly as
2584
+ * a send that does not exist, which is what it is from here.
2585
+ *=========================================*/ const orgForSite = await getOrgForHost(hostId).catch(()=>null);
2586
+ const siteOrgId = String((_ref11 = orgForSite == null ? void 0 : orgForSite.orgId) != null ? _ref11 : '');
2587
+ const orgSends = siteOrgId ? orgCampaignSends(firestore, siteOrgId) : null;
2588
+ const needsOrg = action !== 'proofOptions' && action !== 'test' && action !== 'renderPreview' && action !== 'preview';
2589
+ if (needsOrg && !orgSends) {
2590
+ return res.status(409).json({
2591
+ error: 'This site is not part of an organization, so it cannot hold ' + 'campaign emails.'
2592
+ });
2593
+ }
2594
+ /*
2595
+ * THE CAMPAIGN A SEND JOINS has to be one this site can see.
2596
+ *
2597
+ * The id is a pointer the console chose from a picker, but the request
2598
+ * could carry any container in the org — including one placed only on a
2599
+ * sibling site, which this site's editors cannot read — or one that has
2600
+ * been deleted. Filing a send under either would put it where its own
2601
+ * editors cannot find it. Checked only for the actions that store it.
2602
+ */ if (emailCampaignId && (action === 'draft' || action === 'schedule' || action === 'send')) {
2603
+ const container = await orgEmailCampaigns(firestore, siteOrgId).doc(emailCampaignId).get();
2604
+ if (!container.exists || container.get('deletedAt') != null || !campaignPlacedOnHost({
2605
+ visibleTo: container.get('visibleTo')
2606
+ }, hostId)) {
2607
+ return res.status(400).json({
2608
+ error: 'Unknown campaign'
2609
+ });
2610
+ }
2611
+ }
2541
2612
  if (action === 'proofOptions') {
2542
2613
  var _decoded_email;
2543
2614
  /*
@@ -2565,7 +2636,7 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2565
2636
  });
2566
2637
  }
2567
2638
  if (action === 'test') {
2568
- var _decoded_email1, _ref15, _ref16;
2639
+ var _decoded_email1, _ref16, _ref17;
2569
2640
  var _req_body20, _req_body21;
2570
2641
  /*
2571
2642
  * PROOF ONE EMAIL: as somebody, to somebody, from an identity — and
@@ -2577,7 +2648,7 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2577
2648
  * to. Which identity it leaves on is not among them: that is resolved
2578
2649
  * from the host document, and no request field reaches it.
2579
2650
  */ const ownEmail = String((_decoded_email1 = decoded.email) != null ? _decoded_email1 : '').trim().toLowerCase();
2580
- const requestedTo = String((_ref15 = (_req_body20 = req.body) == null ? void 0 : _req_body20.to) != null ? _ref15 : '').trim().toLowerCase();
2651
+ const requestedTo = String((_ref16 = (_req_body20 = req.body) == null ? void 0 : _req_body20.to) != null ? _ref16 : '').trim().toLowerCase();
2581
2652
  const testEmail = requestedTo || ownEmail;
2582
2653
  if (!testEmail) {
2583
2654
  return res.status(400).json({
@@ -2618,7 +2689,7 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2618
2689
  * taken from the request, so what a proof shows is what the audience
2619
2690
  * document actually holds — a persona assembled from a request would
2620
2691
  * demonstrate the composer's own typing rather than the merge.
2621
- */ const persona = await resolveProofPersona(hostId, String((_ref16 = (_req_body21 = req.body) == null ? void 0 : _req_body21.personaEmail) != null ? _ref16 : ''));
2692
+ */ const persona = await resolveProofPersona(hostId, String((_ref17 = (_req_body21 = req.body) == null ? void 0 : _req_body21.personaEmail) != null ? _ref17 : ''));
2622
2693
  const result = await performCampaignSend(_extends({
2623
2694
  hostId,
2624
2695
  subject,
@@ -2673,8 +2744,7 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2673
2744
  cname: hostSnapshot.get('cname'),
2674
2745
  subdomain: hostSnapshot.get('subdomain')
2675
2746
  })) != null ? _hostPublicOrigin : '';
2676
- const orgForHost = await getOrgForHost(hostId);
2677
- const branding = resolveBrandingProfile(orgForHost == null ? void 0 : orgForHost.org);
2747
+ const branding = resolveBrandingProfile(orgForSite == null ? void 0 : orgForSite.org);
2678
2748
  /*
2679
2749
  * Personalized for the REQUESTER, because a preview showing raw
2680
2750
  * `{{firstName|there}}` tells a merchant nothing about what a recipient
@@ -2712,7 +2782,7 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2712
2782
  } : {}));
2713
2783
  }
2714
2784
  if (action === 'preview') {
2715
- var _ref17, _ref18;
2785
+ var _ref18, _ref19;
2716
2786
  var _req_body22, _req_body23, _req_body24;
2717
2787
  // Read-only, and it needs the same admin/editor role as a send: the
2718
2788
  // audience size of someone else's site is not public information.
@@ -2721,8 +2791,8 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2721
2791
  subject: subject || 'preview',
2722
2792
  body: body || 'preview',
2723
2793
  audience,
2724
- segmentId: String((_ref17 = (_req_body22 = req.body) == null ? void 0 : _req_body22.segmentId) != null ? _ref17 : ''),
2725
- listId: String((_ref18 = (_req_body23 = req.body) == null ? void 0 : _req_body23.listId) != null ? _ref18 : ''),
2794
+ segmentId: String((_ref18 = (_req_body22 = req.body) == null ? void 0 : _req_body22.segmentId) != null ? _ref18 : ''),
2795
+ listId: String((_ref19 = (_req_body23 = req.body) == null ? void 0 : _req_body23.listId) != null ? _ref19 : ''),
2726
2796
  topicId: topicId || undefined,
2727
2797
  emails: Array.isArray((_req_body24 = req.body) == null ? void 0 : _req_body24.emails) ? req.body.emails.map(String) : undefined,
2728
2798
  templateScreenId: templateScreenId || undefined
@@ -2735,7 +2805,7 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2735
2805
  return res.status(200).json(result);
2736
2806
  }
2737
2807
  if (action === 'followUp') {
2738
- var _ref19;
2808
+ var _ref20;
2739
2809
  var _req_body25, _req_body26;
2740
2810
  /*
2741
2811
  * SEND AN EMAIL THAT HAS ALREADY GONE OUT TO THE PEOPLE IT HAS NOT
@@ -2751,14 +2821,14 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2751
2821
  * `dryRun` rides through so the console can ask how many people are
2752
2822
  * left before offering the button, and the answer comes from the code
2753
2823
  * that would do the sending rather than from a count of its own.
2754
- */ const followUpId = String((_ref19 = (_req_body25 = req.body) == null ? void 0 : _req_body25.campaignId) != null ? _ref19 : '');
2824
+ */ const followUpId = String((_ref20 = (_req_body25 = req.body) == null ? void 0 : _req_body25.campaignId) != null ? _ref20 : '');
2755
2825
  if (!isDocumentId(followUpId)) {
2756
2826
  return res.status(400).json({
2757
2827
  error: 'Invalid campaignId'
2758
2828
  });
2759
2829
  }
2760
- const sendSnapshot = await hostRef.collection('campaigns').doc(followUpId).get();
2761
- if (!sendSnapshot.exists) {
2830
+ const sendSnapshot = await orgSends.doc(followUpId).get();
2831
+ if (!sendSnapshot.exists || !sendIsOnHost(sendSnapshot, hostId)) {
2762
2832
  return res.status(404).json({
2763
2833
  error: 'Unknown email'
2764
2834
  });
@@ -2794,16 +2864,16 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2794
2864
  * escape on its own; `performCampaignSend` is the only thing that mails
2795
2865
  * it, and only when somebody asks.
2796
2866
  *=========================================*/ if (action === 'draft' || action === 'schedule' || action === 'update') {
2797
- var _ref20, _ref21, _targetSnapshot_get;
2867
+ var _ref21, _ref22, _targetSnapshot_get;
2798
2868
  var _req_body27, _req_body28, _req_body29, _req_body30, _req_body31, _req_body32;
2799
2869
  const scheduling = action === 'schedule';
2800
- const sendAtMs = Number((_ref20 = (_req_body27 = req.body) == null ? void 0 : _req_body27.sendAtMs) != null ? _ref20 : 0);
2870
+ const sendAtMs = Number((_ref21 = (_req_body27 = req.body) == null ? void 0 : _req_body27.sendAtMs) != null ? _ref21 : 0);
2801
2871
  if (scheduling && (!Number.isFinite(sendAtMs) || sendAtMs <= Date.now())) {
2802
2872
  return res.status(400).json({
2803
2873
  error: 'Pick a future send time'
2804
2874
  });
2805
2875
  }
2806
- const campaignId = String((_ref21 = (_req_body28 = req.body) == null ? void 0 : _req_body28.campaignId) != null ? _ref21 : '') || createResourceUid();
2876
+ const campaignId = String((_ref22 = (_req_body28 = req.body) == null ? void 0 : _req_body28.campaignId) != null ? _ref22 : '') || createResourceUid();
2807
2877
  // AGL-1771: this branch WRITES, and it is the only campaign write that
2808
2878
  // does not go through `performCampaignSend`'s guard. A `campaignId` of
2809
2879
  // `a/b/c` scheduled the campaign at `campaigns/a/b/c` — which the
@@ -2815,8 +2885,17 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2815
2885
  error: 'Invalid campaignId'
2816
2886
  });
2817
2887
  }
2818
- const targetRef = hostRef.collection('campaigns').doc(campaignId);
2888
+ const targetRef = orgSends.doc(campaignId);
2819
2889
  const targetSnapshot = await targetRef.get();
2890
+ /*
2891
+ * An id that names a sibling site's send is refused, not adopted: the
2892
+ * merge below would otherwise rewrite that site's email under this
2893
+ * site's name.
2894
+ */ if (targetSnapshot.exists && !sendIsOnHost(targetSnapshot, hostId)) {
2895
+ return res.status(404).json({
2896
+ error: 'Unknown email'
2897
+ });
2898
+ }
2820
2899
  const targetState = targetSnapshot.exists ? String((_targetSnapshot_get = targetSnapshot.get('status')) != null ? _targetSnapshot_get : '') : '';
2821
2900
  /*==========================================
2822
2901
  * WHAT IS ALREADY IN AN INBOX IS NOT EDITABLE.
@@ -2851,9 +2930,9 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2851
2930
  error: 'Unknown email'
2852
2931
  });
2853
2932
  }
2854
- await targetRef.set({
2933
+ await targetRef.set(_extends({}, campaignSendSiteStamp(hostId), {
2855
2934
  displayName
2856
- }, {
2935
+ }), {
2857
2936
  merge: true
2858
2937
  });
2859
2938
  return res.status(200).json({
@@ -2861,7 +2940,7 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2861
2940
  displayName
2862
2941
  });
2863
2942
  }
2864
- await targetRef.set(_extends({
2943
+ await targetRef.set(_extends({}, campaignSendSiteStamp(hostId), {
2865
2944
  subject,
2866
2945
  body,
2867
2946
  audience
@@ -2938,9 +3017,9 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2938
3017
  });
2939
3018
  }
2940
3019
  if (action === 'cancel') {
2941
- var _ref22;
3020
+ var _ref23;
2942
3021
  var _req_body33;
2943
- const campaignId = String((_ref22 = (_req_body33 = req.body) == null ? void 0 : _req_body33.campaignId) != null ? _ref22 : '');
3022
+ const campaignId = String((_ref23 = (_req_body33 = req.body) == null ? void 0 : _req_body33.campaignId) != null ? _ref23 : '');
2944
3023
  // AGL-1771: the ref used to be built one line ABOVE the `campaignId ?`
2945
3024
  // check below, which defeated that check — `.doc('')` throws on an empty
2946
3025
  // path segment, so the 400 this branch intends became a 500. Guarding
@@ -2950,18 +3029,18 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2950
3029
  error: 'Not a scheduled campaign'
2951
3030
  });
2952
3031
  }
2953
- const campaignRef = hostRef.collection('campaigns').doc(campaignId);
3032
+ const campaignRef = orgSends.doc(campaignId);
2954
3033
  const campaignSnapshot = await campaignRef.get();
2955
- if (!(campaignSnapshot == null ? void 0 : campaignSnapshot.exists) || campaignSnapshot.get('status') !== 'scheduled') {
3034
+ if (!(campaignSnapshot == null ? void 0 : campaignSnapshot.exists) || !sendIsOnHost(campaignSnapshot, hostId) || campaignSnapshot.get('status') !== 'scheduled') {
2956
3035
  return res.status(400).json({
2957
3036
  error: 'Not a scheduled campaign'
2958
3037
  });
2959
3038
  }
2960
- await campaignRef.set({
3039
+ await campaignRef.set(_extends({}, campaignSendSiteStamp(hostId), {
2961
3040
  status: 'canceled',
2962
3041
  canceledAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),
2963
3042
  canceledBy: decoded.uid
2964
- }, {
3043
+ }), {
2965
3044
  merge: true
2966
3045
  });
2967
3046
  return res.status(200).json({
@@ -2970,7 +3049,7 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2970
3049
  });
2971
3050
  }
2972
3051
  if (action === 'sendNow') {
2973
- var _ref23, _sendNowSnapshot_get, _ref24;
3052
+ var _ref24, _sendNowSnapshot_get, _ref25;
2974
3053
  var _req_body34, _sendNowSnapshot_get1;
2975
3054
  /*==========================================
2976
3055
  * MAIL A DRAFTED OR SCHEDULED EMAIL, NOW.
@@ -2987,15 +3066,15 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2987
3066
  * lists, the same topic filtering, the same monthly reservation and the
2988
3067
  * same hourly governor — because it is the same function the scheduled
2989
3068
  * processor and the composer both call.
2990
- *=========================================*/ const sendNowId = String((_ref23 = (_req_body34 = req.body) == null ? void 0 : _req_body34.campaignId) != null ? _ref23 : '');
3069
+ *=========================================*/ const sendNowId = String((_ref24 = (_req_body34 = req.body) == null ? void 0 : _req_body34.campaignId) != null ? _ref24 : '');
2991
3070
  if (!isDocumentId(sendNowId)) {
2992
3071
  return res.status(400).json({
2993
3072
  error: 'Invalid campaignId'
2994
3073
  });
2995
3074
  }
2996
- const sendNowRef = hostRef.collection('campaigns').doc(sendNowId);
3075
+ const sendNowRef = orgSends.doc(sendNowId);
2997
3076
  const sendNowSnapshot = await sendNowRef.get();
2998
- if (!sendNowSnapshot.exists) {
3077
+ if (!sendNowSnapshot.exists || !sendIsOnHost(sendNowSnapshot, hostId)) {
2999
3078
  return res.status(404).json({
3000
3079
  error: 'Unknown email'
3001
3080
  });
@@ -3028,7 +3107,7 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3028
3107
  * beat — so there is nothing for a merchant to ask for here, and
3029
3108
  * "Send now" on a send already in flight is a request made under a
3030
3109
  * misunderstanding the answer should correct.
3031
- *=========================================*/ const resumeRemaining = Math.floor(Number((_ref24 = (_sendNowSnapshot_get1 = sendNowSnapshot.get('resume')) == null ? void 0 : _sendNowSnapshot_get1.remaining) != null ? _ref24 : 0));
3110
+ *=========================================*/ const resumeRemaining = Math.floor(Number((_ref25 = (_sendNowSnapshot_get1 = sendNowSnapshot.get('resume')) == null ? void 0 : _sendNowSnapshot_get1.remaining) != null ? _ref25 : 0));
3032
3111
  if (Number.isFinite(resumeRemaining) && resumeRemaining > 0) {
3033
3112
  return res.status(409).json({
3034
3113
  error: 'This email is already being sent, and the rest of it goes out ' + 'on its own. Sending it now would mail a second copy to ' + 'everyone it has already reached.'
@@ -3102,7 +3181,7 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3102
3181
  * Reaching the people an existing email has NOT reached is `followUp`,
3103
3182
  * which takes no copy from the request at all and subtracts everyone the
3104
3183
  * earlier sends recorded.
3105
- *=========================================*/ const sendId = String((_ref11 = (_req_body15 = req.body) == null ? void 0 : _req_body15.campaignId) != null ? _ref11 : '');
3184
+ *=========================================*/ const sendId = String((_ref12 = (_req_body15 = req.body) == null ? void 0 : _req_body15.campaignId) != null ? _ref12 : '');
3106
3185
  if (sendId) {
3107
3186
  var _existing_get;
3108
3187
  if (!isDocumentId(sendId)) {
@@ -3110,7 +3189,12 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3110
3189
  error: 'Invalid campaignId'
3111
3190
  });
3112
3191
  }
3113
- const existing = await hostRef.collection('campaigns').doc(sendId).get();
3192
+ const existing = await orgSends.doc(sendId).get();
3193
+ if (existing.exists && !sendIsOnHost(existing, hostId)) {
3194
+ return res.status(404).json({
3195
+ error: 'Unknown email'
3196
+ });
3197
+ }
3114
3198
  const existingState = existing.exists ? String((_existing_get = existing.get('status')) != null ? _existing_get : '') : '';
3115
3199
  if (existing.exists && existingState !== 'draft' && existingState !== 'scheduled') {
3116
3200
  return res.status(409).json({
@@ -3123,12 +3207,12 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3123
3207
  subject,
3124
3208
  body,
3125
3209
  audience,
3126
- segmentId: String((_ref12 = (_req_body16 = req.body) == null ? void 0 : _req_body16.segmentId) != null ? _ref12 : ''),
3127
- listId: String((_ref13 = (_req_body17 = req.body) == null ? void 0 : _req_body17.listId) != null ? _ref13 : ''),
3210
+ segmentId: String((_ref13 = (_req_body16 = req.body) == null ? void 0 : _req_body16.segmentId) != null ? _ref13 : ''),
3211
+ listId: String((_ref14 = (_req_body17 = req.body) == null ? void 0 : _req_body17.listId) != null ? _ref14 : ''),
3128
3212
  topicId: topicId || undefined,
3129
3213
  emails: Array.isArray((_req_body18 = req.body) == null ? void 0 : _req_body18.emails) ? req.body.emails : undefined,
3130
3214
  campaignId: sendId,
3131
- experimentId: String((_ref14 = (_req_body19 = req.body) == null ? void 0 : _req_body19.experimentId) != null ? _ref14 : ''),
3215
+ experimentId: String((_ref15 = (_req_body19 = req.body) == null ? void 0 : _req_body19.experimentId) != null ? _ref15 : ''),
3132
3216
  templateScreenId: templateScreenId || undefined
3133
3217
  }, plainText ? {
3134
3218
  plainText