@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.
- package/package.json +16 -16
- package/src/lib/components/campaign-composer.js +58 -15
- package/src/lib/components/campaign-composer.js.map +1 -1
- package/src/lib/components/email-detail.js +70 -27
- package/src/lib/components/email-detail.js.map +1 -1
- package/src/lib/model/campaign-plan-lock.d.ts +60 -0
- package/src/lib/model/campaign-plan-lock.js +77 -0
- package/src/lib/model/campaign-plan-lock.js.map +1 -0
- package/src/lib/model/index.d.ts +1 -0
- package/src/lib/model/index.js +1 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/server/campaign-send.d.ts +12 -2
- package/src/lib/server/campaign-send.js +104 -27
- package/src/lib/server/campaign-send.js.map +1 -1
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
}, ((
|
|
3423
|
+
}, ((_req_body30 = req.body) == null ? void 0 : _req_body30.segmentId) ? {
|
|
3352
3424
|
segmentId: String(req.body.segmentId)
|
|
3353
|
-
} : {}, ((
|
|
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((
|
|
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
|
-
} : {}, ((
|
|
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
|
|
3426
|
-
const campaignId = String((_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
|
|
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 = (
|
|
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
|
|
3552
|
-
const
|
|
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 ((
|
|
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({
|