mailery 0.16.6 → 0.16.8

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 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 findOffDomainLinkHosts(html, fromEmail) {
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, fromDomain)) continue;
5389
+ if (own.some((o) => sameSite(host, o))) continue;
5329
5390
  off++;
5330
5391
  hosts.add(host);
5331
5392
  }
@@ -6389,9 +6450,30 @@ function createAdminApiRouter(mailer, opts = {}) {
6389
6450
  const seenNames = await c.events.distinct("name");
6390
6451
  const known = new Set(registered.map((r2) => r2.name));
6391
6452
  const unregistered = seenNames.filter((n) => n && !known.has(n));
6453
+ const now = Date.now();
6454
+ const d7 = new Date(now - 7 * 864e5);
6455
+ const d30 = new Date(now - 30 * 864e5);
6456
+ const rows = await c.events.aggregate([
6457
+ {
6458
+ $group: {
6459
+ _id: "$name",
6460
+ total: { $sum: 1 },
6461
+ last7d: { $sum: { $cond: [{ $gte: ["$createdAt", d7] }, 1, 0] } },
6462
+ last30d: { $sum: { $cond: [{ $gte: ["$createdAt", d30] }, 1, 0] } },
6463
+ lastAt: { $max: "$createdAt" },
6464
+ firstAt: { $min: "$createdAt" }
6465
+ }
6466
+ }
6467
+ ]).toArray();
6468
+ const stats = {};
6469
+ for (const r2 of rows) {
6470
+ if (!r2._id) continue;
6471
+ stats[r2._id] = { total: r2.total, last7d: r2.last7d, last30d: r2.last30d, firstAt: r2.firstAt, lastAt: r2.lastAt };
6472
+ }
6392
6473
  res.json({
6393
6474
  registered: registered.sort((a, b) => a.name.localeCompare(b.name)),
6394
- seen: unregistered.sort()
6475
+ seen: unregistered.sort(),
6476
+ stats
6395
6477
  });
6396
6478
  })
6397
6479
  );
@@ -7353,7 +7435,12 @@ function createAdminApiRouter(mailer, opts = {}) {
7353
7435
  if (!plainText && typeof tpl.body?.plainText === "string") plainText = tpl.body.plainText;
7354
7436
  const lint = lintTemplate(
7355
7437
  { subject, preheader, mjml, editorJson, html, plainText, kind, fromEmail },
7356
- { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
7438
+ {
7439
+ senderDomains: mailer.config.senderDomains,
7440
+ varsJsonSchema: varsSchema,
7441
+ publicUrl: mailer.config.publicUrl,
7442
+ linkDomains: mailer.config.linkDomains
7443
+ }
7357
7444
  );
7358
7445
  res.json({ ...lint, compileFailed: false });
7359
7446
  })
@@ -7404,7 +7491,12 @@ function createAdminApiRouter(mailer, opts = {}) {
7404
7491
  kind: tpl.kind,
7405
7492
  fromEmail: tpl.fromEmail
7406
7493
  },
7407
- { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
7494
+ {
7495
+ senderDomains: mailer.config.senderDomains,
7496
+ varsJsonSchema: varsSchema,
7497
+ publicUrl: mailer.config.publicUrl,
7498
+ linkDomains: mailer.config.linkDomains
7499
+ }
7408
7500
  );
7409
7501
  if (lint.errors.length > 0) {
7410
7502
  return res.status(422).json({
@@ -8271,7 +8363,7 @@ function unitToMs2(value, unit) {
8271
8363
  }
8272
8364
 
8273
8365
  // src/server/api/agent.ts
8274
- var VERSION = "0.16.6" ;
8366
+ var VERSION = "0.16.8" ;
8275
8367
  var agentTagsInputSchema = zod.z.object({
8276
8368
  add: zod.z.array(zod.z.string().min(1).max(128)).max(25).default([]),
8277
8369
  remove: zod.z.array(zod.z.string().min(1).max(128)).max(25).default([])
@@ -8520,7 +8612,12 @@ function createAgentRouter(mailer, opts) {
8520
8612
  kind: input.kind,
8521
8613
  fromEmail: input.fromEmail
8522
8614
  },
8523
- { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
8615
+ {
8616
+ senderDomains: mailer.config.senderDomains,
8617
+ varsJsonSchema: varsSchema,
8618
+ publicUrl: mailer.config.publicUrl,
8619
+ linkDomains: mailer.config.linkDomains
8620
+ }
8524
8621
  );
8525
8622
  if (lint.errors.length > 0) {
8526
8623
  return res.status(422).json({
@@ -8851,15 +8948,15 @@ function createAgentRouter(mailer, opts) {
8851
8948
  const contact = await loadContact(res, String(req.params.externalId));
8852
8949
  if (!contact) return;
8853
8950
  if (!guardTestContact(res, contact)) return;
8854
- await mailer.upsertSubscription({ externalId: contact.externalId, source: "agent" });
8951
+ const { removedSuppressions } = await mailer.resubscribe({ externalId: contact.externalId, source: "agent" });
8855
8952
  const sub = await c.subscriptions.findOne({ externalId: contact.externalId });
8856
8953
  await mailer.audit({
8857
8954
  actor: actorOf(req),
8858
8955
  action: "agent.contact.subscribe",
8859
8956
  resource: { collection: "mailer_subscriptions", id: sub?._id },
8860
- diffSummary: contact.email
8957
+ diffSummary: `${contact.email} (removed ${removedSuppressions} opt-out suppression${removedSuppressions === 1 ? "" : "s"})`
8861
8958
  });
8862
- res.json({ subscription: sub });
8959
+ res.json({ subscription: sub, removedSuppressions });
8863
8960
  })
8864
8961
  );
8865
8962
  router.post(
@@ -9184,7 +9281,12 @@ async function verifyTemplate(mailer, tpl, contact, opts = {}) {
9184
9281
  kind: tpl.kind,
9185
9282
  fromEmail: tpl.fromEmail
9186
9283
  },
9187
- { senderDomains: mailer.config.senderDomains, varsJsonSchema: opts.varsSchema ?? null }
9284
+ {
9285
+ senderDomains: mailer.config.senderDomains,
9286
+ varsJsonSchema: opts.varsSchema ?? null,
9287
+ publicUrl: mailer.config.publicUrl,
9288
+ linkDomains: mailer.config.linkDomains
9289
+ }
9188
9290
  );
9189
9291
  push("lint", lint.errors.length ? "fail" : lint.warnings.length ? "warn" : "pass", {
9190
9292
  errors: lint.errors.map((i) => ({ rule: i.rule, message: i.message })),
@@ -9430,7 +9532,7 @@ var ENDPOINTS = [
9430
9532
  { method: "GET", path: "/contacts/:externalId", summary: "Contact with subscription, suppressions, recent events, sends and runs." },
9431
9533
  { method: "GET", path: "/contacts/by-email/:email", summary: "Same, looked up by email." },
9432
9534
  { 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 },
9433
- { method: "POST", path: "/contacts/:externalId/subscribe", summary: "Subscribe a test contact.", testContactsOnly: true },
9535
+ { method: "POST", path: "/contacts/:externalId/subscribe", summary: "Subscribe a test contact, clearing any opt-out suppression it has (never bounce/complaint rows).", testContactsOnly: true },
9434
9536
  { method: "POST", path: "/contacts/:externalId/unsubscribe", summary: "Unsubscribe a test contact (marketing scope).", testContactsOnly: true },
9435
9537
  { 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 },
9436
9538
  { 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 },
@@ -10149,7 +10251,7 @@ var DEDUPE_POLICIES = [
10149
10251
  ];
10150
10252
 
10151
10253
  // src/server/index.ts
10152
- var VERSION2 = "0.16.6" ;
10254
+ var VERSION2 = "0.16.8" ;
10153
10255
 
10154
10256
  exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
10155
10257
  exports.DEFAULT_BOT_UA_RE = DEFAULT_BOT_UA_RE;