mailery 0.16.5 → 0.16.7
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/dist/index.cjs +135 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +135 -14
- package/dist/index.js.map +1 -1
- package/dist/{null-CDlseQxO.d.cts → null-oKPS74Gr.d.cts} +43 -0
- package/dist/{null-CDlseQxO.d.ts → null-oKPS74Gr.d.ts} +43 -0
- package/dist/testing.cjs +50 -0
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +2 -2
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +50 -0
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -471,6 +471,15 @@ var unsubscribeInputSchema = zod.z.object({
|
|
|
471
471
|
source: zod.z.string().max(256).default("manual"),
|
|
472
472
|
notes: zod.z.string().max(1024).optional()
|
|
473
473
|
});
|
|
474
|
+
var resubscribeInputSchema = zod.z.object({
|
|
475
|
+
externalId: externalIdSchema,
|
|
476
|
+
/** `marketing` clears marketing + all-scope opt-outs; `all` clears every scope. */
|
|
477
|
+
scope: zod.z.enum(["marketing", "all"]).default("marketing"),
|
|
478
|
+
source: zod.z.string().min(1).max(256),
|
|
479
|
+
consentTimestamp: zod.z.date().optional(),
|
|
480
|
+
consentIp: zod.z.string().optional(),
|
|
481
|
+
consentUserAgent: zod.z.string().optional()
|
|
482
|
+
});
|
|
474
483
|
var suppressInputSchema = zod.z.object({
|
|
475
484
|
email: emailSchema,
|
|
476
485
|
scope: unsubscribeScopeSchema,
|
|
@@ -1032,6 +1041,15 @@ function verifyDoiToken(token, secret, now = /* @__PURE__ */ new Date()) {
|
|
|
1032
1041
|
}
|
|
1033
1042
|
|
|
1034
1043
|
// src/server/unsubscribe.ts
|
|
1044
|
+
async function clearUnsubscribeSuppressions(collections, email, scope) {
|
|
1045
|
+
const scopes = scope === "all" ? ["all", "marketing", "transactional"] : ["marketing", "all"];
|
|
1046
|
+
const result = await collections.suppressions.deleteMany({
|
|
1047
|
+
email,
|
|
1048
|
+
reason: "unsubscribed",
|
|
1049
|
+
scope: { $in: scopes }
|
|
1050
|
+
});
|
|
1051
|
+
return result.deletedCount ?? 0;
|
|
1052
|
+
}
|
|
1035
1053
|
async function applyUnsubscribe(collections, input, now = /* @__PURE__ */ new Date()) {
|
|
1036
1054
|
const normalized = input.email;
|
|
1037
1055
|
await collections.suppressions.updateOne(
|
|
@@ -4735,6 +4753,38 @@ var Mailer = class _Mailer {
|
|
|
4735
4753
|
}
|
|
4736
4754
|
}
|
|
4737
4755
|
}
|
|
4756
|
+
/**
|
|
4757
|
+
* An explicit opt-in from a contact who unsubscribed before.
|
|
4758
|
+
*
|
|
4759
|
+
* `upsertSubscription` alone is not enough: an unsubscribe also writes a
|
|
4760
|
+
* `mailer_suppressions` row, and the suppression check runs at enqueue
|
|
4761
|
+
* time regardless of subscription status — so a contact re-subscribed
|
|
4762
|
+
* through `upsertSubscription` reads as subscribed while every send comes
|
|
4763
|
+
* back `suppressed`. This clears the opt-out rows (only those: a bounce or
|
|
4764
|
+
* complaint is not the contact's to reverse), then upserts the subscription
|
|
4765
|
+
* through the normal path, double opt-in included.
|
|
4766
|
+
*
|
|
4767
|
+
* Deliberately a separate method rather than a side effect of
|
|
4768
|
+
* `upsertSubscription`, so a backfill or a model hook that re-upserts every
|
|
4769
|
+
* account cannot silently resurrect addresses that opted out.
|
|
4770
|
+
*/
|
|
4771
|
+
async resubscribe(input) {
|
|
4772
|
+
const parsed = resubscribeInputSchema.parse(input);
|
|
4773
|
+
const contact = await this.adapter.getById(parsed.externalId);
|
|
4774
|
+
if (!contact) throw new Error(`adapter has no contact for externalId ${parsed.externalId}`);
|
|
4775
|
+
const removedSuppressions = await clearUnsubscribeSuppressions(this.collections, contact.email, parsed.scope);
|
|
4776
|
+
const { scope: _scope, ...subscription } = parsed;
|
|
4777
|
+
await this.upsertSubscription(subscription);
|
|
4778
|
+
if (removedSuppressions > 0) {
|
|
4779
|
+
await this.audit({
|
|
4780
|
+
actor: `host:${parsed.source}`,
|
|
4781
|
+
action: "contact.resubscribe",
|
|
4782
|
+
resource: { collection: "mailer_suppressions", id: parsed.externalId },
|
|
4783
|
+
diffSummary: `${contact.email}: removed ${removedSuppressions} unsubscribed suppression${removedSuppressions === 1 ? "" : "s"} (${parsed.scope})`
|
|
4784
|
+
});
|
|
4785
|
+
}
|
|
4786
|
+
return { removedSuppressions };
|
|
4787
|
+
}
|
|
4738
4788
|
/**
|
|
4739
4789
|
* The writes live in `server/unsubscribe.ts` so the pending-unsubscribe
|
|
4740
4790
|
* drain (INVARIANT 8) replays a journaled opt-out through exactly this path
|
|
@@ -5226,7 +5276,7 @@ ${input.plainText}`);
|
|
|
5226
5276
|
});
|
|
5227
5277
|
}
|
|
5228
5278
|
}
|
|
5229
|
-
const offDomain = findOffDomainLinkHosts(input.html, input.fromEmail);
|
|
5279
|
+
const offDomain = findOffDomainLinkHosts(input.html, input.fromEmail, ownHosts(config));
|
|
5230
5280
|
if (offDomain.majority && offDomain.hosts.length > 0) {
|
|
5231
5281
|
issues.push({
|
|
5232
5282
|
rule: "offdomain_links",
|
|
@@ -5311,12 +5361,23 @@ function hostnameOf(url) {
|
|
|
5311
5361
|
}
|
|
5312
5362
|
}
|
|
5313
5363
|
function hasBareUrlInVisibleText(html) {
|
|
5314
|
-
const stripped = html.replace(/<a\b[^>]*>.*?<\/a>/gis, " ").replace(/<style\b[^>]*>.*?<\/style>/gis, " ").replace(/<script\b[^>]*>.*?<\/script>/gis, " ").replace(/<head\b[^>]*>.*?<\/head>/gis, " ");
|
|
5364
|
+
const stripped = html.replace(/<a\b[^>]*>.*?<\/a>/gis, " ").replace(/<style\b[^>]*>.*?<\/style>/gis, " ").replace(/<script\b[^>]*>.*?<\/script>/gis, " ").replace(/<head\b[^>]*>.*?<\/head>/gis, " ").replace(/<[^>]*>/g, " ");
|
|
5315
5365
|
return /https?:\/\/[^\s<>"']+/i.test(stripped);
|
|
5316
5366
|
}
|
|
5317
|
-
function
|
|
5367
|
+
function ownHosts(config) {
|
|
5368
|
+
const hosts = [];
|
|
5369
|
+
const publicHost = config.publicUrl ? hostnameOf(config.publicUrl) : null;
|
|
5370
|
+
if (publicHost) hosts.push(publicHost);
|
|
5371
|
+
for (const d of config.linkDomains ?? []) {
|
|
5372
|
+
const host = hostnameOf(/^https?:\/\//i.test(d) ? d : `https://${d}`);
|
|
5373
|
+
if (host) hosts.push(host);
|
|
5374
|
+
}
|
|
5375
|
+
return hosts;
|
|
5376
|
+
}
|
|
5377
|
+
function findOffDomainLinkHosts(html, fromEmail, extraOwnHosts = []) {
|
|
5318
5378
|
const fromDomain = fromEmail.split("@")[1]?.toLowerCase();
|
|
5319
5379
|
if (!fromDomain) return { hosts: [], majority: false };
|
|
5380
|
+
const own = [fromDomain, ...extraOwnHosts.map((h) => h.toLowerCase())];
|
|
5320
5381
|
const hosts = /* @__PURE__ */ new Set();
|
|
5321
5382
|
let total = 0;
|
|
5322
5383
|
let off = 0;
|
|
@@ -5325,7 +5386,7 @@ function findOffDomainLinkHosts(html, fromEmail) {
|
|
|
5325
5386
|
const host = hostnameOf(href);
|
|
5326
5387
|
if (!host) continue;
|
|
5327
5388
|
total++;
|
|
5328
|
-
if (sameSite(host,
|
|
5389
|
+
if (own.some((o) => sameSite(host, o))) continue;
|
|
5329
5390
|
off++;
|
|
5330
5391
|
hosts.add(host);
|
|
5331
5392
|
}
|
|
@@ -7353,7 +7414,12 @@ function createAdminApiRouter(mailer, opts = {}) {
|
|
|
7353
7414
|
if (!plainText && typeof tpl.body?.plainText === "string") plainText = tpl.body.plainText;
|
|
7354
7415
|
const lint = lintTemplate(
|
|
7355
7416
|
{ subject, preheader, mjml, editorJson, html, plainText, kind, fromEmail },
|
|
7356
|
-
{
|
|
7417
|
+
{
|
|
7418
|
+
senderDomains: mailer.config.senderDomains,
|
|
7419
|
+
varsJsonSchema: varsSchema,
|
|
7420
|
+
publicUrl: mailer.config.publicUrl,
|
|
7421
|
+
linkDomains: mailer.config.linkDomains
|
|
7422
|
+
}
|
|
7357
7423
|
);
|
|
7358
7424
|
res.json({ ...lint, compileFailed: false });
|
|
7359
7425
|
})
|
|
@@ -7404,7 +7470,12 @@ function createAdminApiRouter(mailer, opts = {}) {
|
|
|
7404
7470
|
kind: tpl.kind,
|
|
7405
7471
|
fromEmail: tpl.fromEmail
|
|
7406
7472
|
},
|
|
7407
|
-
{
|
|
7473
|
+
{
|
|
7474
|
+
senderDomains: mailer.config.senderDomains,
|
|
7475
|
+
varsJsonSchema: varsSchema,
|
|
7476
|
+
publicUrl: mailer.config.publicUrl,
|
|
7477
|
+
linkDomains: mailer.config.linkDomains
|
|
7478
|
+
}
|
|
7408
7479
|
);
|
|
7409
7480
|
if (lint.errors.length > 0) {
|
|
7410
7481
|
return res.status(422).json({
|
|
@@ -8271,7 +8342,11 @@ function unitToMs2(value, unit) {
|
|
|
8271
8342
|
}
|
|
8272
8343
|
|
|
8273
8344
|
// src/server/api/agent.ts
|
|
8274
|
-
var VERSION = "0.16.
|
|
8345
|
+
var VERSION = "0.16.7" ;
|
|
8346
|
+
var agentTagsInputSchema = zod.z.object({
|
|
8347
|
+
add: zod.z.array(zod.z.string().min(1).max(128)).max(25).default([]),
|
|
8348
|
+
remove: zod.z.array(zod.z.string().min(1).max(128)).max(25).default([])
|
|
8349
|
+
});
|
|
8275
8350
|
var publishTemplateInputSchema = zod.z.object({
|
|
8276
8351
|
name: zod.z.string().min(1).max(200),
|
|
8277
8352
|
description: zod.z.string().max(2e3).default(""),
|
|
@@ -8516,7 +8591,12 @@ function createAgentRouter(mailer, opts) {
|
|
|
8516
8591
|
kind: input.kind,
|
|
8517
8592
|
fromEmail: input.fromEmail
|
|
8518
8593
|
},
|
|
8519
|
-
{
|
|
8594
|
+
{
|
|
8595
|
+
senderDomains: mailer.config.senderDomains,
|
|
8596
|
+
varsJsonSchema: varsSchema,
|
|
8597
|
+
publicUrl: mailer.config.publicUrl,
|
|
8598
|
+
linkDomains: mailer.config.linkDomains
|
|
8599
|
+
}
|
|
8520
8600
|
);
|
|
8521
8601
|
if (lint.errors.length > 0) {
|
|
8522
8602
|
return res.status(422).json({
|
|
@@ -8847,15 +8927,15 @@ function createAgentRouter(mailer, opts) {
|
|
|
8847
8927
|
const contact = await loadContact(res, String(req.params.externalId));
|
|
8848
8928
|
if (!contact) return;
|
|
8849
8929
|
if (!guardTestContact(res, contact)) return;
|
|
8850
|
-
await mailer.
|
|
8930
|
+
const { removedSuppressions } = await mailer.resubscribe({ externalId: contact.externalId, source: "agent" });
|
|
8851
8931
|
const sub = await c.subscriptions.findOne({ externalId: contact.externalId });
|
|
8852
8932
|
await mailer.audit({
|
|
8853
8933
|
actor: actorOf(req),
|
|
8854
8934
|
action: "agent.contact.subscribe",
|
|
8855
8935
|
resource: { collection: "mailer_subscriptions", id: sub?._id },
|
|
8856
|
-
diffSummary: contact.email
|
|
8936
|
+
diffSummary: `${contact.email} (removed ${removedSuppressions} opt-out suppression${removedSuppressions === 1 ? "" : "s"})`
|
|
8857
8937
|
});
|
|
8858
|
-
res.json({ subscription: sub });
|
|
8938
|
+
res.json({ subscription: sub, removedSuppressions });
|
|
8859
8939
|
})
|
|
8860
8940
|
);
|
|
8861
8941
|
router.post(
|
|
@@ -8875,6 +8955,41 @@ function createAgentRouter(mailer, opts) {
|
|
|
8875
8955
|
res.json({ subscription: sub });
|
|
8876
8956
|
})
|
|
8877
8957
|
);
|
|
8958
|
+
router.post(
|
|
8959
|
+
"/contacts/:externalId/tags",
|
|
8960
|
+
wrap2(async (req, res) => {
|
|
8961
|
+
const contact = await loadContact(res, String(req.params.externalId));
|
|
8962
|
+
if (!contact) return;
|
|
8963
|
+
if (!guardTestContact(res, contact)) return;
|
|
8964
|
+
const parsed = agentTagsInputSchema.safeParse(req.body ?? {});
|
|
8965
|
+
if (!parsed.success) {
|
|
8966
|
+
return res.status(400).json({ error: "validation_failed", issues: parsed.error.issues });
|
|
8967
|
+
}
|
|
8968
|
+
const { add, remove } = parsed.data;
|
|
8969
|
+
if (add.length === 0 && remove.length === 0) {
|
|
8970
|
+
return res.status(400).json({ error: "no_tags", message: "Pass {add: [...]} and/or {remove: [...]}." });
|
|
8971
|
+
}
|
|
8972
|
+
const overlap = add.filter((t) => remove.includes(t));
|
|
8973
|
+
if (overlap.length > 0) {
|
|
8974
|
+
return res.status(400).json({ error: "tag_conflict", tags: overlap });
|
|
8975
|
+
}
|
|
8976
|
+
for (const tag of add) await mailer.tag(contact.externalId, tag);
|
|
8977
|
+
for (const tag of remove) await mailer.untag(contact.externalId, tag);
|
|
8978
|
+
const after = await mailer.adapter.getById(contact.externalId);
|
|
8979
|
+
await mailer.audit({
|
|
8980
|
+
actor: actorOf(req),
|
|
8981
|
+
action: "agent.contact.tags",
|
|
8982
|
+
resource: { collection: "contacts", id: contact.externalId },
|
|
8983
|
+
diffSummary: `${contact.email}: ${add.length ? `+${add.join(", +")}` : ""}${add.length && remove.length ? " " : ""}${remove.length ? `-${remove.join(", -")}` : ""}`
|
|
8984
|
+
});
|
|
8985
|
+
res.json({
|
|
8986
|
+
contact: { externalId: contact.externalId, email: contact.email },
|
|
8987
|
+
added: add,
|
|
8988
|
+
removed: remove,
|
|
8989
|
+
tags: after?.tags ?? []
|
|
8990
|
+
});
|
|
8991
|
+
})
|
|
8992
|
+
);
|
|
8878
8993
|
router.post(
|
|
8879
8994
|
"/contacts/:externalId/reset",
|
|
8880
8995
|
wrap2(async (req, res) => {
|
|
@@ -9145,7 +9260,12 @@ async function verifyTemplate(mailer, tpl, contact, opts = {}) {
|
|
|
9145
9260
|
kind: tpl.kind,
|
|
9146
9261
|
fromEmail: tpl.fromEmail
|
|
9147
9262
|
},
|
|
9148
|
-
{
|
|
9263
|
+
{
|
|
9264
|
+
senderDomains: mailer.config.senderDomains,
|
|
9265
|
+
varsJsonSchema: opts.varsSchema ?? null,
|
|
9266
|
+
publicUrl: mailer.config.publicUrl,
|
|
9267
|
+
linkDomains: mailer.config.linkDomains
|
|
9268
|
+
}
|
|
9149
9269
|
);
|
|
9150
9270
|
push("lint", lint.errors.length ? "fail" : lint.warnings.length ? "warn" : "pass", {
|
|
9151
9271
|
errors: lint.errors.map((i) => ({ rule: i.rule, message: i.message })),
|
|
@@ -9391,8 +9511,9 @@ var ENDPOINTS = [
|
|
|
9391
9511
|
{ method: "GET", path: "/contacts/:externalId", summary: "Contact with subscription, suppressions, recent events, sends and runs." },
|
|
9392
9512
|
{ method: "GET", path: "/contacts/by-email/:email", summary: "Same, looked up by email." },
|
|
9393
9513
|
{ method: "GET", path: "/contacts/:externalId/unsubscribe-url", summary: "A signed one-click unsubscribe URL for a test contact, to exercise POST /m/unsub/:token.", testContactsOnly: true },
|
|
9394
|
-
{ method: "POST", path: "/contacts/:externalId/subscribe", summary: "Subscribe a test contact.", testContactsOnly: true },
|
|
9514
|
+
{ method: "POST", path: "/contacts/:externalId/subscribe", summary: "Subscribe a test contact, clearing any opt-out suppression it has (never bounce/complaint rows).", testContactsOnly: true },
|
|
9395
9515
|
{ method: "POST", path: "/contacts/:externalId/unsubscribe", summary: "Unsubscribe a test contact (marketing scope).", testContactsOnly: true },
|
|
9516
|
+
{ method: "POST", path: "/contacts/:externalId/tags", summary: "Add or remove tags on a test contact ({add: [...], remove: [...]}), so a gated flow lets it through.", testContactsOnly: true },
|
|
9396
9517
|
{ method: "POST", path: "/contacts/:externalId/reset", summary: "Delete a test contact's runs, sends, events ({events: [names]} to narrow) and suppressions, then resubscribe. Each part can be turned off with false.", testContactsOnly: true },
|
|
9397
9518
|
{ method: "POST", path: "/tick", summary: "Run the runner tick now (trigger scan, sweeps, outbox, webhook backlog)." },
|
|
9398
9519
|
{ method: "GET", path: "/webhooks/status", summary: "Provider webhook ingest: last event received, counts by type (24h), unprocessed backlog." },
|
|
@@ -10109,7 +10230,7 @@ var DEDUPE_POLICIES = [
|
|
|
10109
10230
|
];
|
|
10110
10231
|
|
|
10111
10232
|
// src/server/index.ts
|
|
10112
|
-
var VERSION2 = "0.16.
|
|
10233
|
+
var VERSION2 = "0.16.7" ;
|
|
10113
10234
|
|
|
10114
10235
|
exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
|
|
10115
10236
|
exports.DEFAULT_BOT_UA_RE = DEFAULT_BOT_UA_RE;
|