@aglyn/plugins-marketing 1.0.0-beta.278 → 1.0.0-beta.283

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.
@@ -14,6 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ import { type CampaignPlanLock } from '../model/campaign-plan-lock';
17
18
  import { type PluginApiHandler } from '@aglyn/aglyn/server';
18
19
  import { type SendingIdentitySource } from '@aglyn/shared-util-email';
19
20
  import { type ListUnsubscribeDecision } from '@aglyn/shared-util-email/list-unsubscribe';
@@ -28,10 +29,19 @@ import { type ListUnsubscribeDecision } from '@aglyn/shared-util-email/list-unsu
28
29
  export declare function suppressionId(email: string): string;
29
30
  /** HMAC for unsubscribe links; env-gated on the shared secret. */
30
31
  export declare function unsubscribeSignature(hostId: string, email: string, secret: string, campaignId?: string, topicId?: string): string;
31
- /** Send failures carry the HTTP status the API route should answer. */
32
+ /**
33
+ * Send failures carry the HTTP status the API route should answer, and a
34
+ * machine `reason` when the console answers that refusal with something
35
+ * other than the sentence (the plan cap's Upgrade button).
36
+ */
32
37
  export declare class CampaignSendError extends Error {
33
38
  readonly status: number;
34
- constructor(message: string, status: number);
39
+ readonly reason?: string;
40
+ /** The plan lock behind a plan-cap refusal, for a client to draw. */
41
+ readonly planLock?: CampaignPlanLock | null;
42
+ constructor(message: string, status: number, reason?: string,
43
+ /** The plan lock behind a plan-cap refusal, for a client to draw. */
44
+ planLock?: CampaignPlanLock | null);
35
45
  }
36
46
  /**
37
47
  * The platform send rate had no room for this campaign THIS HOUR (AGL-2409).
@@ -19,6 +19,7 @@ import { readListingRevocation } from "@aglyn/aglyn/plugin-manager/plugin-revoca
19
19
  import { renderRecipientEmail } from "@aglyn/aglyn/app-utils/recipient-email-render";
20
20
  import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-referenced-components";
21
21
  import { assignExperimentVariant } from "../model/index.js";
22
+ import { CAMPAIGN_PLAN_CAP_REASON, campaignPlanCapMessage, campaignPlanLock } from "../model/campaign-plan-lock.js";
22
23
  import { campaignHeldForReviewNotice, campaignPlacedOnHost, campaignSendHeldForReview } from "../model/campaign-container.js";
23
24
  import { readPluginRecordCard } from "@aglyn/aglyn/plugin-manager/plugin-record-cards";
24
25
  import { hostPublicOrigin } from "@aglyn/aglyn/server";
@@ -184,9 +185,13 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
184
185
  * module's published surface.
185
186
  */ return sharedUnsubscribeSignature(hostId, email, secret, campaignId, topicId);
186
187
  }
187
- /** Send failures carry the HTTP status the API route should answer. */ export class CampaignSendError extends Error {
188
- constructor(message, status){
189
- super(message), this.status = status;
188
+ /**
189
+ * Send failures carry the HTTP status the API route should answer, and a
190
+ * machine `reason` when the console answers that refusal with something
191
+ * other than the sentence (the plan cap's Upgrade button).
192
+ */ export class CampaignSendError extends Error {
193
+ constructor(message, status, reason, /** The plan lock behind a plan-cap refusal, for a client to draw. */ planLock){
194
+ super(message), this.status = status, this.reason = reason, this.planLock = planLock;
190
195
  }
191
196
  }
192
197
  /**
@@ -1324,15 +1329,29 @@ export async function performCampaignSend(options) {
1324
1329
  // a usage of 0 is how a plain limit is read; the ALLOW/REFUSE decision is
1325
1330
  // not made here — it is made by the atomic reservation below.
1326
1331
  const campaignSendLimit = checkQuota(orgForHost == null ? void 0 : orgForHost.org, 'emailSendsPerMonth', 0).limit;
1327
- const overCapError = ()=>new CampaignSendError(`Monthly campaign email limit reached (${campaignSendLimit}) — upgrade ` + 'in Billing or shrink the audience. Transactional mail — receipts, ' + 'booking reminders, password resets — keeps sending.', 403);
1332
+ // Names the plan that would carry this campaign, so a refusal that reaches
1333
+ // a merchant anyway — a native composer, a scheduled send — still says
1334
+ // what to do. `campaignPlanLock` is the same rule the composer locks Send
1335
+ // with.
1336
+ const overCapError = (used)=>{
1337
+ const lock = campaignPlanLock({
1338
+ limit: campaignSendLimit,
1339
+ used,
1340
+ sendable: sendable.length
1341
+ });
1342
+ return new CampaignSendError(lock ? campaignPlanCapMessage(lock) : `Monthly campaign email limit reached (${campaignSendLimit}) — upgrade ` + 'in Billing or shrink the audience. Transactional mail — receipts, ' + 'booking reminders, password resets — keeps sending.', 403, CAMPAIGN_PLAN_CAP_REASON, lock);
1343
+ };
1344
+ // Read before the pre-check below and kept, so the race refusal after the
1345
+ // atomic claim can still say how much of the allowance was spent.
1346
+ const usedAtPreCheck = await orgCampaignEmailSendsForMonth(orgId, monthKey);
1328
1347
  {
1329
1348
  // A cheap read-only pre-check, so an over-cap campaign is refused before
1330
1349
  // the template load, the experiment read and the campaign id — and so the
1331
1350
  // DRY RUN has an answer without writing anything (AGL-2178: "nothing has
1332
1351
  // been written above this line"). It is not the enforcement; it cannot be,
1333
1352
  // because a read is not a claim.
1334
- const used = await orgCampaignEmailSendsForMonth(orgId, monthKey);
1335
- if (used + sendable.length > campaignSendLimit) throw overCapError();
1353
+ const used = usedAtPreCheck;
1354
+ if (used + sendable.length > campaignSendLimit) throw overCapError(used);
1336
1355
  }
1337
1356
  /*
1338
1357
  * Recipient PREVIEW (AGL-2178). The campaign composer mockup shows
@@ -1681,7 +1700,9 @@ export async function performCampaignSend(options) {
1681
1700
  // has not been entered yet, which is exactly why this cannot be left to
1682
1701
  // it.
1683
1702
  await reconcileOrgEmailSendDay(dayClaim.reservation, 0);
1684
- throw overCapError();
1703
+ // A concurrent campaign took the rest between the pre-check and here, so
1704
+ // the allowance is at least spent down to this campaign's size.
1705
+ throw overCapError(Math.max(usedAtPreCheck, campaignSendLimit - sendable.length + 1));
1685
1706
  }
1686
1707
  const reservation = claim.reservation;
1687
1708
  const dayReservation = dayClaim.reservation;
@@ -3171,12 +3192,41 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3171
3192
  replyTo
3172
3193
  } : {}));
3173
3194
  }
3195
+ /*
3196
+ * THE PLAN'S ANSWER RIDES EVERY DRY RUN, refused or not.
3197
+ *
3198
+ * A composer asks for a preview the moment it opens, and "Send now" and
3199
+ * "Send to more recipients" ask for a dry run before they offer their
3200
+ * confirm, so this is where a plan without campaign email is said before
3201
+ * any button that could only fail.
3202
+ * The native composers draw `planLock` as it comes rather than porting
3203
+ * the rule; the console computes the same `campaignPlanLock` from the
3204
+ * same limit and counter. Read only when the answer is assembled, and
3205
+ * a refusal that already carries its own lock (the cap) keeps it.
3206
+ *
3207
+ * A site outside an organization holds no campaign allowance to read.
3208
+ */ const planLockFor = async (sendable)=>siteOrgId ? campaignPlanLock({
3209
+ limit: checkQuota(orgForSite == null ? void 0 : orgForSite.org, 'emailSendsPerMonth', 0).limit,
3210
+ used: await orgCampaignEmailSendsForMonth(siteOrgId, new Date().toISOString().slice(0, 7)),
3211
+ sendable
3212
+ }) : null;
3213
+ const withPlanLock = (dryRun)=>dryRun.then(async (result)=>{
3214
+ var _result_sendable;
3215
+ return _extends({}, result, {
3216
+ planLock: await planLockFor(Number((_result_sendable = result.sendable) != null ? _result_sendable : 0))
3217
+ });
3218
+ }, async (error)=>{
3219
+ if (error instanceof CampaignSendError && !error.planLock) {
3220
+ throw new CampaignSendError(error.message, error.status, error.reason, await planLockFor());
3221
+ }
3222
+ throw error;
3223
+ });
3174
3224
  if (action === 'preview') {
3175
3225
  var _ref18, _ref19;
3176
3226
  var _req_body22, _req_body23, _req_body24;
3177
3227
  // Read-only, and it needs the same admin/editor role as a send: the
3178
3228
  // audience size of someone else's site is not public information.
3179
- const result = await performCampaignSend(_extends({
3229
+ const result = await withPlanLock(performCampaignSend(_extends({
3180
3230
  hostId,
3181
3231
  subject: subject || 'preview',
3182
3232
  body: body || 'preview',
@@ -3191,12 +3241,12 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3191
3241
  } : {}, {
3192
3242
  senderUid: decoded.uid,
3193
3243
  dryRun: true
3194
- }));
3244
+ })));
3195
3245
  return res.status(200).json(result);
3196
3246
  }
3197
3247
  if (action === 'followUp') {
3198
3248
  var _ref20;
3199
- var _req_body25, _req_body26;
3249
+ var _req_body25, _req_body26, _req_body27;
3200
3250
  /*
3201
3251
  * SEND AN EMAIL THAT HAS ALREADY GONE OUT TO THE PEOPLE IT HAS NOT
3202
3252
  * REACHED.
@@ -3223,9 +3273,10 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3223
3273
  error: 'Unknown email'
3224
3274
  });
3225
3275
  }
3226
- const result = await performCampaignSend(_extends({}, storedSendOptionsFrom(sendSnapshot, hostId, decoded.uid, true), ((_req_body26 = req.body) == null ? void 0 : _req_body26.dryRun) ? {
3276
+ const followUpSend = performCampaignSend(_extends({}, storedSendOptionsFrom(sendSnapshot, hostId, decoded.uid, true), ((_req_body26 = req.body) == null ? void 0 : _req_body26.dryRun) ? {
3227
3277
  dryRun: true
3228
3278
  } : {}));
3279
+ const result = await (((_req_body27 = req.body) == null ? void 0 : _req_body27.dryRun) ? withPlanLock(followUpSend) : followUpSend);
3229
3280
  return res.status(200).json(result);
3230
3281
  }
3231
3282
  /*==========================================
@@ -3255,15 +3306,36 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3255
3306
  * it, and only when somebody asks.
3256
3307
  *=========================================*/ if (action === 'draft' || action === 'schedule' || action === 'update') {
3257
3308
  var _ref21, _ref22, _targetSnapshot_get;
3258
- var _req_body27, _req_body28, _req_body29, _req_body30, _req_body31, _req_body32;
3309
+ var _req_body28, _req_body29, _req_body30, _req_body31, _req_body32, _req_body33;
3259
3310
  const scheduling = action === 'schedule';
3260
- const sendAtMs = Number((_ref21 = (_req_body27 = req.body) == null ? void 0 : _req_body27.sendAtMs) != null ? _ref21 : 0);
3311
+ const sendAtMs = Number((_ref21 = (_req_body28 = req.body) == null ? void 0 : _req_body28.sendAtMs) != null ? _ref21 : 0);
3261
3312
  if (scheduling && (!Number.isFinite(sendAtMs) || sendAtMs <= Date.now())) {
3262
3313
  return res.status(400).json({
3263
3314
  error: 'Pick a future send time'
3264
3315
  });
3265
3316
  }
3266
- const campaignId = String((_ref22 = (_req_body28 = req.body) == null ? void 0 : _req_body28.campaignId) != null ? _ref22 : '') || createResourceUid();
3317
+ /*
3318
+ * A PLAN WITH NO CAMPAIGN EMAIL CANNOT SCHEDULE ONE.
3319
+ *
3320
+ * Scheduling writes a document and claims nothing, so without this a
3321
+ * Free workspace could put a campaign on the clock that the processor
3322
+ * can only ever mark `failed` — the refusal arriving hours later, in a
3323
+ * history list, instead of now with an Upgrade path. A plan WITH an
3324
+ * allowance is not refused here: the month may have reset by the send
3325
+ * time, and the send itself enforces the cap.
3326
+ */ if (scheduling) {
3327
+ const lock = campaignPlanLock({
3328
+ limit: checkQuota(orgForSite == null ? void 0 : orgForSite.org, 'emailSendsPerMonth', 0).limit,
3329
+ used: 0
3330
+ });
3331
+ if ((lock == null ? void 0 : lock.kind) === 'not-on-plan') {
3332
+ return res.status(403).json({
3333
+ error: campaignPlanCapMessage(lock),
3334
+ reason: CAMPAIGN_PLAN_CAP_REASON
3335
+ });
3336
+ }
3337
+ }
3338
+ const campaignId = String((_ref22 = (_req_body29 = req.body) == null ? void 0 : _req_body29.campaignId) != null ? _ref22 : '') || createResourceUid();
3267
3339
  // AGL-1771: this branch WRITES, and it is the only campaign write that
3268
3340
  // does not go through `performCampaignSend`'s guard. A `campaignId` of
3269
3341
  // `a/b/c` scheduled the campaign at `campaigns/a/b/c` — which the
@@ -3348,15 +3420,15 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3348
3420
  audience
3349
3421
  }, targetSnapshot.exists ? {} : {
3350
3422
  createdAtMs: Date.now()
3351
- }, ((_req_body29 = req.body) == null ? void 0 : _req_body29.segmentId) ? {
3423
+ }, ((_req_body30 = req.body) == null ? void 0 : _req_body30.segmentId) ? {
3352
3424
  segmentId: String(req.body.segmentId)
3353
- } : {}, ((_req_body30 = req.body) == null ? void 0 : _req_body30.listId) ? {
3425
+ } : {}, ((_req_body31 = req.body) == null ? void 0 : _req_body31.listId) ? {
3354
3426
  listId: String(req.body.listId)
3355
3427
  } : {}, topicId ? {
3356
3428
  topicId
3357
- } : {}, Array.isArray((_req_body31 = req.body) == null ? void 0 : _req_body31.emails) ? {
3429
+ } : {}, Array.isArray((_req_body32 = req.body) == null ? void 0 : _req_body32.emails) ? {
3358
3430
  emails: req.body.emails.map(String).slice(0, 500)
3359
- } : {}, ((_req_body32 = req.body) == null ? void 0 : _req_body32.experimentId) ? {
3431
+ } : {}, ((_req_body33 = req.body) == null ? void 0 : _req_body33.experimentId) ? {
3360
3432
  experimentId: String(req.body.experimentId)
3361
3433
  } : {}, {
3362
3434
  /*
@@ -3422,8 +3494,8 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3422
3494
  }
3423
3495
  if (action === 'cancel') {
3424
3496
  var _ref23;
3425
- var _req_body33;
3426
- const campaignId = String((_ref23 = (_req_body33 = req.body) == null ? void 0 : _req_body33.campaignId) != null ? _ref23 : '');
3497
+ var _req_body34;
3498
+ const campaignId = String((_ref23 = (_req_body34 = req.body) == null ? void 0 : _req_body34.campaignId) != null ? _ref23 : '');
3427
3499
  // AGL-1771: the ref used to be built one line ABOVE the `campaignId ?`
3428
3500
  // check below, which defeated that check — `.doc('')` throws on an empty
3429
3501
  // path segment, so the 400 this branch intends became a 500. Guarding
@@ -3454,7 +3526,7 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3454
3526
  }
3455
3527
  if (action === 'sendNow') {
3456
3528
  var _ref24, _sendNowSnapshot_get, _ref25;
3457
- var _req_body34, _sendNowSnapshot_get1;
3529
+ var _req_body35, _sendNowSnapshot_get1;
3458
3530
  /*==========================================
3459
3531
  * MAIL A DRAFTED OR SCHEDULED EMAIL, NOW.
3460
3532
  *
@@ -3470,7 +3542,7 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3470
3542
  * lists, the same topic filtering, the same monthly reservation and the
3471
3543
  * same hourly governor — because it is the same function the scheduled
3472
3544
  * processor and the composer both call.
3473
- *=========================================*/ const sendNowId = String((_ref24 = (_req_body34 = req.body) == null ? void 0 : _req_body34.campaignId) != null ? _ref24 : '');
3545
+ *=========================================*/ const sendNowId = String((_ref24 = (_req_body35 = req.body) == null ? void 0 : _req_body35.campaignId) != null ? _ref24 : '');
3474
3546
  if (!isDocumentId(sendNowId)) {
3475
3547
  return res.status(400).json({
3476
3548
  error: 'Invalid campaignId'
@@ -3548,15 +3620,16 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3548
3620
  });
3549
3621
  }
3550
3622
  try {
3551
- var _req_body35, _req_body36;
3552
- const result = await performCampaignSend(_extends({}, storedSendOptionsFrom(sendNowSnapshot, hostId, decoded.uid, false), ((_req_body35 = req.body) == null ? void 0 : _req_body35.dryRun) ? {
3623
+ var _req_body36, _req_body37, _req_body38;
3624
+ const sendNowSend = performCampaignSend(_extends({}, storedSendOptionsFrom(sendNowSnapshot, hostId, decoded.uid, false), ((_req_body36 = req.body) == null ? void 0 : _req_body36.dryRun) ? {
3553
3625
  dryRun: true
3554
3626
  } : {}));
3627
+ const result = await (((_req_body37 = req.body) == null ? void 0 : _req_body37.dryRun) ? withPlanLock(sendNowSend) : sendNowSend);
3555
3628
  /*
3556
3629
  * A dry run writes nothing, so the claim above is the only change it
3557
3630
  * made and it has to be put back — otherwise asking how many people
3558
3631
  * an email would reach would leave it stuck in `sending`.
3559
- */ if ((_req_body36 = req.body) == null ? void 0 : _req_body36.dryRun) {
3632
+ */ if ((_req_body38 = req.body) == null ? void 0 : _req_body38.dryRun) {
3560
3633
  await sendNowRef.set({
3561
3634
  status: sendNowState
3562
3635
  }, {
@@ -3645,9 +3718,13 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
3645
3718
  return res.status(200).json(result);
3646
3719
  } catch (error) {
3647
3720
  if (error instanceof CampaignSendError) {
3648
- return res.status(error.status).json({
3721
+ return res.status(error.status).json(_extends({
3649
3722
  error: error.message
3650
- });
3723
+ }, error.reason ? {
3724
+ reason: error.reason
3725
+ } : {}, error.planLock ? {
3726
+ planLock: error.planLock
3727
+ } : {}));
3651
3728
  }
3652
3729
  console.error(error);
3653
3730
  return res.status(500).json({