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.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { R as RunnerContext, N as NormalizedEvent, C as ContactAdapter, a as Contact, A as AdapterFilter, M as MailProvider, S as SendArgs, b as SendResult, c as MailTesterFeedback, d as Mailer, T as TemplateDoc, F as FlowStep, e as Collections, f as FlowDoc, g as SuppressionScope, D as DeliveryWindow, h as SegmentFilter, P as Predicate } from './null-CDlseQxO.cjs';
2
- export { i as AuditLogDoc, B as BotFilterConfig, j as BroadcastDoc, k as BroadcastStatus, l as CircuitBreakerThresholds, m as ContactTagDoc, E as EventDoc, n as FlowGoal, o as FlowRunDoc, p as FlowRunStatus, q as FlowVersionDoc, H as HealthDoc, r as HealthStatus, L as LeadDoc, s as MailerConfig, t as NullProvider, O as OutboxDoc, u as RESERVED_VAR_KEYS, v as RedisOptions, w as SegmentDefinition, x as SendDoc, y as SendStatus, z as SenderDomainConfig, G as SenderDomainRegistry, I as SenderDomainValidation, J as SubscriptionDoc, K as SubscriptionStatus, Q as SuppressionDoc, U as SuppressionReason, V as TemplateKind, W as TemplateVersionDoc, X as VarsAdapter, Y as VarsResolveInfo, Z as WebhookEventDoc, _ as defineVars, $ as ensureIndexes, a0 as getCollections, a1 as validateSenderDomain, a2 as varsJsonSchema } from './null-CDlseQxO.cjs';
1
+ import { R as RunnerContext, N as NormalizedEvent, C as ContactAdapter, a as Contact, A as AdapterFilter, M as MailProvider, S as SendArgs, b as SendResult, c as MailTesterFeedback, d as Mailer, T as TemplateDoc, F as FlowStep, e as Collections, f as FlowDoc, g as SuppressionScope, D as DeliveryWindow, h as SegmentFilter, P as Predicate } from './null-oKPS74Gr.cjs';
2
+ export { i as AuditLogDoc, B as BotFilterConfig, j as BroadcastDoc, k as BroadcastStatus, l as CircuitBreakerThresholds, m as ContactTagDoc, E as EventDoc, n as FlowGoal, o as FlowRunDoc, p as FlowRunStatus, q as FlowVersionDoc, H as HealthDoc, r as HealthStatus, L as LeadDoc, s as MailerConfig, t as NullProvider, O as OutboxDoc, u as RESERVED_VAR_KEYS, v as RedisOptions, w as SegmentDefinition, x as SendDoc, y as SendStatus, z as SenderDomainConfig, G as SenderDomainRegistry, I as SenderDomainValidation, J as SubscriptionDoc, K as SubscriptionStatus, Q as SuppressionDoc, U as SuppressionReason, V as TemplateKind, W as TemplateVersionDoc, X as VarsAdapter, Y as VarsResolveInfo, Z as WebhookEventDoc, _ as defineVars, $ as ensureIndexes, a0 as getCollections, a1 as validateSenderDomain, a2 as varsJsonSchema } from './null-oKPS74Gr.cjs';
3
3
  import { ObjectId, Db, Filter } from 'mongodb';
4
4
  import { Request, Router } from 'express';
5
5
  import Handlebars from 'handlebars';
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { R as RunnerContext, N as NormalizedEvent, C as ContactAdapter, a as Contact, A as AdapterFilter, M as MailProvider, S as SendArgs, b as SendResult, c as MailTesterFeedback, d as Mailer, T as TemplateDoc, F as FlowStep, e as Collections, f as FlowDoc, g as SuppressionScope, D as DeliveryWindow, h as SegmentFilter, P as Predicate } from './null-CDlseQxO.js';
2
- export { i as AuditLogDoc, B as BotFilterConfig, j as BroadcastDoc, k as BroadcastStatus, l as CircuitBreakerThresholds, m as ContactTagDoc, E as EventDoc, n as FlowGoal, o as FlowRunDoc, p as FlowRunStatus, q as FlowVersionDoc, H as HealthDoc, r as HealthStatus, L as LeadDoc, s as MailerConfig, t as NullProvider, O as OutboxDoc, u as RESERVED_VAR_KEYS, v as RedisOptions, w as SegmentDefinition, x as SendDoc, y as SendStatus, z as SenderDomainConfig, G as SenderDomainRegistry, I as SenderDomainValidation, J as SubscriptionDoc, K as SubscriptionStatus, Q as SuppressionDoc, U as SuppressionReason, V as TemplateKind, W as TemplateVersionDoc, X as VarsAdapter, Y as VarsResolveInfo, Z as WebhookEventDoc, _ as defineVars, $ as ensureIndexes, a0 as getCollections, a1 as validateSenderDomain, a2 as varsJsonSchema } from './null-CDlseQxO.js';
1
+ import { R as RunnerContext, N as NormalizedEvent, C as ContactAdapter, a as Contact, A as AdapterFilter, M as MailProvider, S as SendArgs, b as SendResult, c as MailTesterFeedback, d as Mailer, T as TemplateDoc, F as FlowStep, e as Collections, f as FlowDoc, g as SuppressionScope, D as DeliveryWindow, h as SegmentFilter, P as Predicate } from './null-oKPS74Gr.js';
2
+ export { i as AuditLogDoc, B as BotFilterConfig, j as BroadcastDoc, k as BroadcastStatus, l as CircuitBreakerThresholds, m as ContactTagDoc, E as EventDoc, n as FlowGoal, o as FlowRunDoc, p as FlowRunStatus, q as FlowVersionDoc, H as HealthDoc, r as HealthStatus, L as LeadDoc, s as MailerConfig, t as NullProvider, O as OutboxDoc, u as RESERVED_VAR_KEYS, v as RedisOptions, w as SegmentDefinition, x as SendDoc, y as SendStatus, z as SenderDomainConfig, G as SenderDomainRegistry, I as SenderDomainValidation, J as SubscriptionDoc, K as SubscriptionStatus, Q as SuppressionDoc, U as SuppressionReason, V as TemplateKind, W as TemplateVersionDoc, X as VarsAdapter, Y as VarsResolveInfo, Z as WebhookEventDoc, _ as defineVars, $ as ensureIndexes, a0 as getCollections, a1 as validateSenderDomain, a2 as varsJsonSchema } from './null-oKPS74Gr.js';
3
3
  import { ObjectId, Db, Filter } from 'mongodb';
4
4
  import { Request, Router } from 'express';
5
5
  import Handlebars from 'handlebars';
package/dist/index.js CHANGED
@@ -453,6 +453,15 @@ var unsubscribeInputSchema = z.object({
453
453
  source: z.string().max(256).default("manual"),
454
454
  notes: z.string().max(1024).optional()
455
455
  });
456
+ var resubscribeInputSchema = z.object({
457
+ externalId: externalIdSchema,
458
+ /** `marketing` clears marketing + all-scope opt-outs; `all` clears every scope. */
459
+ scope: z.enum(["marketing", "all"]).default("marketing"),
460
+ source: z.string().min(1).max(256),
461
+ consentTimestamp: z.date().optional(),
462
+ consentIp: z.string().optional(),
463
+ consentUserAgent: z.string().optional()
464
+ });
456
465
  var suppressInputSchema = z.object({
457
466
  email: emailSchema,
458
467
  scope: unsubscribeScopeSchema,
@@ -1014,6 +1023,15 @@ function verifyDoiToken(token, secret, now = /* @__PURE__ */ new Date()) {
1014
1023
  }
1015
1024
 
1016
1025
  // src/server/unsubscribe.ts
1026
+ async function clearUnsubscribeSuppressions(collections, email, scope) {
1027
+ const scopes = scope === "all" ? ["all", "marketing", "transactional"] : ["marketing", "all"];
1028
+ const result = await collections.suppressions.deleteMany({
1029
+ email,
1030
+ reason: "unsubscribed",
1031
+ scope: { $in: scopes }
1032
+ });
1033
+ return result.deletedCount ?? 0;
1034
+ }
1017
1035
  async function applyUnsubscribe(collections, input, now = /* @__PURE__ */ new Date()) {
1018
1036
  const normalized = input.email;
1019
1037
  await collections.suppressions.updateOne(
@@ -4717,6 +4735,38 @@ var Mailer = class _Mailer {
4717
4735
  }
4718
4736
  }
4719
4737
  }
4738
+ /**
4739
+ * An explicit opt-in from a contact who unsubscribed before.
4740
+ *
4741
+ * `upsertSubscription` alone is not enough: an unsubscribe also writes a
4742
+ * `mailer_suppressions` row, and the suppression check runs at enqueue
4743
+ * time regardless of subscription status — so a contact re-subscribed
4744
+ * through `upsertSubscription` reads as subscribed while every send comes
4745
+ * back `suppressed`. This clears the opt-out rows (only those: a bounce or
4746
+ * complaint is not the contact's to reverse), then upserts the subscription
4747
+ * through the normal path, double opt-in included.
4748
+ *
4749
+ * Deliberately a separate method rather than a side effect of
4750
+ * `upsertSubscription`, so a backfill or a model hook that re-upserts every
4751
+ * account cannot silently resurrect addresses that opted out.
4752
+ */
4753
+ async resubscribe(input) {
4754
+ const parsed = resubscribeInputSchema.parse(input);
4755
+ const contact = await this.adapter.getById(parsed.externalId);
4756
+ if (!contact) throw new Error(`adapter has no contact for externalId ${parsed.externalId}`);
4757
+ const removedSuppressions = await clearUnsubscribeSuppressions(this.collections, contact.email, parsed.scope);
4758
+ const { scope: _scope, ...subscription } = parsed;
4759
+ await this.upsertSubscription(subscription);
4760
+ if (removedSuppressions > 0) {
4761
+ await this.audit({
4762
+ actor: `host:${parsed.source}`,
4763
+ action: "contact.resubscribe",
4764
+ resource: { collection: "mailer_suppressions", id: parsed.externalId },
4765
+ diffSummary: `${contact.email}: removed ${removedSuppressions} unsubscribed suppression${removedSuppressions === 1 ? "" : "s"} (${parsed.scope})`
4766
+ });
4767
+ }
4768
+ return { removedSuppressions };
4769
+ }
4720
4770
  /**
4721
4771
  * The writes live in `server/unsubscribe.ts` so the pending-unsubscribe
4722
4772
  * drain (INVARIANT 8) replays a journaled opt-out through exactly this path
@@ -5208,7 +5258,7 @@ ${input.plainText}`);
5208
5258
  });
5209
5259
  }
5210
5260
  }
5211
- const offDomain = findOffDomainLinkHosts(input.html, input.fromEmail);
5261
+ const offDomain = findOffDomainLinkHosts(input.html, input.fromEmail, ownHosts(config));
5212
5262
  if (offDomain.majority && offDomain.hosts.length > 0) {
5213
5263
  issues.push({
5214
5264
  rule: "offdomain_links",
@@ -5293,12 +5343,23 @@ function hostnameOf(url) {
5293
5343
  }
5294
5344
  }
5295
5345
  function hasBareUrlInVisibleText(html) {
5296
- const stripped = html.replace(/<a\b[^>]*>.*?<\/a>/gis, " ").replace(/<style\b[^>]*>.*?<\/style>/gis, " ").replace(/<script\b[^>]*>.*?<\/script>/gis, " ").replace(/<head\b[^>]*>.*?<\/head>/gis, " ");
5346
+ 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, " ");
5297
5347
  return /https?:\/\/[^\s<>"']+/i.test(stripped);
5298
5348
  }
5299
- function findOffDomainLinkHosts(html, fromEmail) {
5349
+ function ownHosts(config) {
5350
+ const hosts = [];
5351
+ const publicHost = config.publicUrl ? hostnameOf(config.publicUrl) : null;
5352
+ if (publicHost) hosts.push(publicHost);
5353
+ for (const d of config.linkDomains ?? []) {
5354
+ const host = hostnameOf(/^https?:\/\//i.test(d) ? d : `https://${d}`);
5355
+ if (host) hosts.push(host);
5356
+ }
5357
+ return hosts;
5358
+ }
5359
+ function findOffDomainLinkHosts(html, fromEmail, extraOwnHosts = []) {
5300
5360
  const fromDomain = fromEmail.split("@")[1]?.toLowerCase();
5301
5361
  if (!fromDomain) return { hosts: [], majority: false };
5362
+ const own = [fromDomain, ...extraOwnHosts.map((h) => h.toLowerCase())];
5302
5363
  const hosts = /* @__PURE__ */ new Set();
5303
5364
  let total = 0;
5304
5365
  let off = 0;
@@ -5307,7 +5368,7 @@ function findOffDomainLinkHosts(html, fromEmail) {
5307
5368
  const host = hostnameOf(href);
5308
5369
  if (!host) continue;
5309
5370
  total++;
5310
- if (sameSite(host, fromDomain)) continue;
5371
+ if (own.some((o) => sameSite(host, o))) continue;
5311
5372
  off++;
5312
5373
  hosts.add(host);
5313
5374
  }
@@ -6371,9 +6432,30 @@ function createAdminApiRouter(mailer, opts = {}) {
6371
6432
  const seenNames = await c.events.distinct("name");
6372
6433
  const known = new Set(registered.map((r2) => r2.name));
6373
6434
  const unregistered = seenNames.filter((n) => n && !known.has(n));
6435
+ const now = Date.now();
6436
+ const d7 = new Date(now - 7 * 864e5);
6437
+ const d30 = new Date(now - 30 * 864e5);
6438
+ const rows = await c.events.aggregate([
6439
+ {
6440
+ $group: {
6441
+ _id: "$name",
6442
+ total: { $sum: 1 },
6443
+ last7d: { $sum: { $cond: [{ $gte: ["$createdAt", d7] }, 1, 0] } },
6444
+ last30d: { $sum: { $cond: [{ $gte: ["$createdAt", d30] }, 1, 0] } },
6445
+ lastAt: { $max: "$createdAt" },
6446
+ firstAt: { $min: "$createdAt" }
6447
+ }
6448
+ }
6449
+ ]).toArray();
6450
+ const stats = {};
6451
+ for (const r2 of rows) {
6452
+ if (!r2._id) continue;
6453
+ stats[r2._id] = { total: r2.total, last7d: r2.last7d, last30d: r2.last30d, firstAt: r2.firstAt, lastAt: r2.lastAt };
6454
+ }
6374
6455
  res.json({
6375
6456
  registered: registered.sort((a, b) => a.name.localeCompare(b.name)),
6376
- seen: unregistered.sort()
6457
+ seen: unregistered.sort(),
6458
+ stats
6377
6459
  });
6378
6460
  })
6379
6461
  );
@@ -7335,7 +7417,12 @@ function createAdminApiRouter(mailer, opts = {}) {
7335
7417
  if (!plainText && typeof tpl.body?.plainText === "string") plainText = tpl.body.plainText;
7336
7418
  const lint = lintTemplate(
7337
7419
  { subject, preheader, mjml, editorJson, html, plainText, kind, fromEmail },
7338
- { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
7420
+ {
7421
+ senderDomains: mailer.config.senderDomains,
7422
+ varsJsonSchema: varsSchema,
7423
+ publicUrl: mailer.config.publicUrl,
7424
+ linkDomains: mailer.config.linkDomains
7425
+ }
7339
7426
  );
7340
7427
  res.json({ ...lint, compileFailed: false });
7341
7428
  })
@@ -7386,7 +7473,12 @@ function createAdminApiRouter(mailer, opts = {}) {
7386
7473
  kind: tpl.kind,
7387
7474
  fromEmail: tpl.fromEmail
7388
7475
  },
7389
- { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
7476
+ {
7477
+ senderDomains: mailer.config.senderDomains,
7478
+ varsJsonSchema: varsSchema,
7479
+ publicUrl: mailer.config.publicUrl,
7480
+ linkDomains: mailer.config.linkDomains
7481
+ }
7390
7482
  );
7391
7483
  if (lint.errors.length > 0) {
7392
7484
  return res.status(422).json({
@@ -8253,7 +8345,7 @@ function unitToMs2(value, unit) {
8253
8345
  }
8254
8346
 
8255
8347
  // src/server/api/agent.ts
8256
- var VERSION = "0.16.6" ;
8348
+ var VERSION = "0.16.8" ;
8257
8349
  var agentTagsInputSchema = z.object({
8258
8350
  add: z.array(z.string().min(1).max(128)).max(25).default([]),
8259
8351
  remove: z.array(z.string().min(1).max(128)).max(25).default([])
@@ -8502,7 +8594,12 @@ function createAgentRouter(mailer, opts) {
8502
8594
  kind: input.kind,
8503
8595
  fromEmail: input.fromEmail
8504
8596
  },
8505
- { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
8597
+ {
8598
+ senderDomains: mailer.config.senderDomains,
8599
+ varsJsonSchema: varsSchema,
8600
+ publicUrl: mailer.config.publicUrl,
8601
+ linkDomains: mailer.config.linkDomains
8602
+ }
8506
8603
  );
8507
8604
  if (lint.errors.length > 0) {
8508
8605
  return res.status(422).json({
@@ -8833,15 +8930,15 @@ function createAgentRouter(mailer, opts) {
8833
8930
  const contact = await loadContact(res, String(req.params.externalId));
8834
8931
  if (!contact) return;
8835
8932
  if (!guardTestContact(res, contact)) return;
8836
- await mailer.upsertSubscription({ externalId: contact.externalId, source: "agent" });
8933
+ const { removedSuppressions } = await mailer.resubscribe({ externalId: contact.externalId, source: "agent" });
8837
8934
  const sub = await c.subscriptions.findOne({ externalId: contact.externalId });
8838
8935
  await mailer.audit({
8839
8936
  actor: actorOf(req),
8840
8937
  action: "agent.contact.subscribe",
8841
8938
  resource: { collection: "mailer_subscriptions", id: sub?._id },
8842
- diffSummary: contact.email
8939
+ diffSummary: `${contact.email} (removed ${removedSuppressions} opt-out suppression${removedSuppressions === 1 ? "" : "s"})`
8843
8940
  });
8844
- res.json({ subscription: sub });
8941
+ res.json({ subscription: sub, removedSuppressions });
8845
8942
  })
8846
8943
  );
8847
8944
  router.post(
@@ -9166,7 +9263,12 @@ async function verifyTemplate(mailer, tpl, contact, opts = {}) {
9166
9263
  kind: tpl.kind,
9167
9264
  fromEmail: tpl.fromEmail
9168
9265
  },
9169
- { senderDomains: mailer.config.senderDomains, varsJsonSchema: opts.varsSchema ?? null }
9266
+ {
9267
+ senderDomains: mailer.config.senderDomains,
9268
+ varsJsonSchema: opts.varsSchema ?? null,
9269
+ publicUrl: mailer.config.publicUrl,
9270
+ linkDomains: mailer.config.linkDomains
9271
+ }
9170
9272
  );
9171
9273
  push("lint", lint.errors.length ? "fail" : lint.warnings.length ? "warn" : "pass", {
9172
9274
  errors: lint.errors.map((i) => ({ rule: i.rule, message: i.message })),
@@ -9412,7 +9514,7 @@ var ENDPOINTS = [
9412
9514
  { method: "GET", path: "/contacts/:externalId", summary: "Contact with subscription, suppressions, recent events, sends and runs." },
9413
9515
  { method: "GET", path: "/contacts/by-email/:email", summary: "Same, looked up by email." },
9414
9516
  { 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 },
9415
- { method: "POST", path: "/contacts/:externalId/subscribe", summary: "Subscribe a test contact.", testContactsOnly: true },
9517
+ { method: "POST", path: "/contacts/:externalId/subscribe", summary: "Subscribe a test contact, clearing any opt-out suppression it has (never bounce/complaint rows).", testContactsOnly: true },
9416
9518
  { method: "POST", path: "/contacts/:externalId/unsubscribe", summary: "Unsubscribe a test contact (marketing scope).", testContactsOnly: true },
9417
9519
  { 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 },
9418
9520
  { 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 },
@@ -10131,7 +10233,7 @@ var DEDUPE_POLICIES = [
10131
10233
  ];
10132
10234
 
10133
10235
  // src/server/index.ts
10134
- var VERSION2 = "0.16.6" ;
10236
+ var VERSION2 = "0.16.8" ;
10135
10237
 
10136
10238
  export { DEDUPE_POLICIES, DEFAULT_BOT_UA_RE, FLOW_STEP_KINDS, FlowOperationError, MIN_AGENT_TOKEN_LENGTH, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, RESERVED_VAR_KEYS, SEGMENT_FILTER_KINDS, SendGridProvider, TRACKING_SIG_LENGTH, VERSION2 as VERSION, applyTracking, applyWebhookEvent, armFlow, compileMailyTemplate, compileTemplate, computeDeliveryTime, createAdminApiRouter, createAdminRouter, createAgentRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, defineVars, derivePlaintext, disarmFlow, dispatchSend, drainPendingUnsubscribes, ensureIndexes, gateFlow, getCollections, isCanaryGate, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, referencedPaths, renderForContact, renderTemplate, runTick, sendgridInboundParser, sha256Hex, signTrackingToken, signUnsubscribeToken, simulateFlow, stampWatermarkIfNull, sweepStrandedFlowRuns, ungateFlow, validateSenderDomain, varsJsonSchema, verifyTemplate, verifyTrackingToken, verifyUnsubscribeToken };
10137
10239
  //# sourceMappingURL=index.js.map