mailery 0.16.4 → 0.16.6

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
@@ -8271,7 +8271,35 @@ function unitToMs2(value, unit) {
8271
8271
  }
8272
8272
 
8273
8273
  // src/server/api/agent.ts
8274
- var VERSION = "0.16.4" ;
8274
+ var VERSION = "0.16.6" ;
8275
+ var agentTagsInputSchema = zod.z.object({
8276
+ add: zod.z.array(zod.z.string().min(1).max(128)).max(25).default([]),
8277
+ remove: zod.z.array(zod.z.string().min(1).max(128)).max(25).default([])
8278
+ });
8279
+ var publishTemplateInputSchema = zod.z.object({
8280
+ name: zod.z.string().min(1).max(200),
8281
+ description: zod.z.string().max(2e3).default(""),
8282
+ kind: zod.z.enum(["marketing", "transactional"]),
8283
+ fromName: zod.z.string().min(1).max(200),
8284
+ fromEmail: zod.z.string().email(),
8285
+ replyTo: zod.z.string().email().nullable().default(null),
8286
+ providerOverride: zod.z.string().min(1).nullable().default(null),
8287
+ subject: zod.z.string().min(1).max(998),
8288
+ preheader: zod.z.string().max(998).default(""),
8289
+ body: zod.z.object({
8290
+ mjml: zod.z.string().default(""),
8291
+ editorJson: zod.z.record(zod.z.string(), zod.z.unknown()).nullable().default(null),
8292
+ html: zod.z.string().default(""),
8293
+ plainText: zod.z.string().default("")
8294
+ }),
8295
+ variablesSchema: zod.z.record(zod.z.string(), zod.z.unknown()).default({}),
8296
+ tags: zod.z.array(zod.z.string().min(1).max(100)).max(50).default([]),
8297
+ bodyFormat: zod.z.enum(["multipart", "text_only"]).default("multipart"),
8298
+ trackOpens: zod.z.boolean().default(true),
8299
+ trackClicks: zod.z.boolean().default(true),
8300
+ /** Recorded as the publisher; defaults to the token's actor. */
8301
+ publishedBy: zod.z.string().min(1).max(200).optional()
8302
+ });
8275
8303
  var MIN_AGENT_TOKEN_LENGTH = 24;
8276
8304
  function createAgentRouter(mailer, opts) {
8277
8305
  if (!opts || !Array.isArray(opts.tokens) || opts.tokens.length === 0) {
@@ -8454,6 +8482,119 @@ function createAgentRouter(mailer, opts) {
8454
8482
  res.status(201).json({ sendId, dedupeKey, dispatched: dispatchNow, send: send ? sendSummary(send) : null });
8455
8483
  })
8456
8484
  );
8485
+ router.put(
8486
+ "/templates/:slug",
8487
+ wrap2(async (req, res) => {
8488
+ const slugParse = slugSchema.safeParse(String(req.params.slug));
8489
+ if (!slugParse.success) {
8490
+ return res.status(400).json({ error: "validation_failed", message: "slug must be lowercase letters, digits and hyphens" });
8491
+ }
8492
+ const slug = slugParse.data;
8493
+ const parsed = publishTemplateInputSchema.safeParse(req.body ?? {});
8494
+ if (!parsed.success) {
8495
+ return res.status(400).json({
8496
+ error: "validation_failed",
8497
+ message: parsed.error.issues.map((i) => `${i.path.join(".") || "body"}: ${i.message}`).join("; ")
8498
+ });
8499
+ }
8500
+ const input = parsed.data;
8501
+ if (!input.body.html.trim()) {
8502
+ return res.status(400).json({
8503
+ error: "empty_body",
8504
+ message: "body.html is required \u2014 this route publishes compiled HTML; a draft goes through POST /api/templates/:slug/publish"
8505
+ });
8506
+ }
8507
+ const senderCheck = validateSenderDomain(input.fromEmail, input.kind, mailer.config.senderDomains);
8508
+ if (!senderCheck.ok) {
8509
+ return res.status(400).json({ error: "sender_domain_invalid", code: senderCheck.code, message: senderCheck.reason });
8510
+ }
8511
+ const plainText = input.body.plainText.trim() ? input.body.plainText : derivePlaintext(input.body.html);
8512
+ const lint = lintTemplate(
8513
+ {
8514
+ subject: input.subject,
8515
+ preheader: input.preheader,
8516
+ mjml: input.body.mjml,
8517
+ editorJson: input.body.editorJson ?? void 0,
8518
+ html: input.body.html,
8519
+ plainText,
8520
+ kind: input.kind,
8521
+ fromEmail: input.fromEmail
8522
+ },
8523
+ { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
8524
+ );
8525
+ if (lint.errors.length > 0) {
8526
+ return res.status(422).json({
8527
+ error: "lint_failed",
8528
+ message: `Template publish blocked by ${lint.errors.length} content issue(s).`,
8529
+ lint
8530
+ });
8531
+ }
8532
+ const now = /* @__PURE__ */ new Date();
8533
+ const publishedBy = input.publishedBy ?? actorOf(req);
8534
+ const set = {
8535
+ slug,
8536
+ name: input.name,
8537
+ description: input.description,
8538
+ kind: input.kind,
8539
+ fromName: input.fromName,
8540
+ fromEmail: input.fromEmail,
8541
+ replyTo: input.replyTo,
8542
+ providerOverride: input.providerOverride,
8543
+ subject: input.subject,
8544
+ preheader: input.preheader,
8545
+ body: {
8546
+ mjml: input.body.mjml,
8547
+ editorJson: input.body.editorJson,
8548
+ html: input.body.html,
8549
+ plainText,
8550
+ compiledAt: now
8551
+ },
8552
+ variablesSchema: input.variablesSchema,
8553
+ draft: null,
8554
+ tags: input.tags,
8555
+ bodyFormat: input.bodyFormat,
8556
+ trackOpens: input.trackOpens,
8557
+ trackClicks: input.trackClicks,
8558
+ publishedAt: now,
8559
+ publishedBy,
8560
+ updatedAt: now
8561
+ };
8562
+ const result = await c.templates.updateOne(
8563
+ { slug },
8564
+ {
8565
+ $set: set,
8566
+ $setOnInsert: {
8567
+ createdAt: now,
8568
+ stats: { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0, lastSentAt: null }
8569
+ }
8570
+ },
8571
+ { upsert: true }
8572
+ );
8573
+ const created = result.upsertedCount > 0;
8574
+ const stored = await c.templates.findOne({ slug });
8575
+ await mailer.audit({
8576
+ actor: actorOf(req),
8577
+ action: "agent.template.publish",
8578
+ resource: { collection: "mailer_templates", id: stored?._id, slug },
8579
+ diffSummary: `${created ? "created" : "updated"} kind=${input.kind} html=${Buffer.byteLength(input.body.html, "utf8")}B trackOpens=${input.trackOpens} trackClicks=${input.trackClicks}`
8580
+ });
8581
+ res.status(created ? 201 : 200).json({
8582
+ slug,
8583
+ created,
8584
+ lint: { warnings: lint.warnings, infos: lint.infos },
8585
+ template: {
8586
+ slug,
8587
+ kind: input.kind,
8588
+ subject: input.subject,
8589
+ fromEmail: input.fromEmail,
8590
+ trackOpens: input.trackOpens,
8591
+ trackClicks: input.trackClicks,
8592
+ publishedAt: now,
8593
+ publishedBy
8594
+ }
8595
+ });
8596
+ })
8597
+ );
8457
8598
  router.get(
8458
8599
  "/sends/:id/wait",
8459
8600
  wrap2(async (req, res) => {
@@ -8738,6 +8879,41 @@ function createAgentRouter(mailer, opts) {
8738
8879
  res.json({ subscription: sub });
8739
8880
  })
8740
8881
  );
8882
+ router.post(
8883
+ "/contacts/:externalId/tags",
8884
+ wrap2(async (req, res) => {
8885
+ const contact = await loadContact(res, String(req.params.externalId));
8886
+ if (!contact) return;
8887
+ if (!guardTestContact(res, contact)) return;
8888
+ const parsed = agentTagsInputSchema.safeParse(req.body ?? {});
8889
+ if (!parsed.success) {
8890
+ return res.status(400).json({ error: "validation_failed", issues: parsed.error.issues });
8891
+ }
8892
+ const { add, remove } = parsed.data;
8893
+ if (add.length === 0 && remove.length === 0) {
8894
+ return res.status(400).json({ error: "no_tags", message: "Pass {add: [...]} and/or {remove: [...]}." });
8895
+ }
8896
+ const overlap = add.filter((t) => remove.includes(t));
8897
+ if (overlap.length > 0) {
8898
+ return res.status(400).json({ error: "tag_conflict", tags: overlap });
8899
+ }
8900
+ for (const tag of add) await mailer.tag(contact.externalId, tag);
8901
+ for (const tag of remove) await mailer.untag(contact.externalId, tag);
8902
+ const after = await mailer.adapter.getById(contact.externalId);
8903
+ await mailer.audit({
8904
+ actor: actorOf(req),
8905
+ action: "agent.contact.tags",
8906
+ resource: { collection: "contacts", id: contact.externalId },
8907
+ diffSummary: `${contact.email}: ${add.length ? `+${add.join(", +")}` : ""}${add.length && remove.length ? " " : ""}${remove.length ? `-${remove.join(", -")}` : ""}`
8908
+ });
8909
+ res.json({
8910
+ contact: { externalId: contact.externalId, email: contact.email },
8911
+ added: add,
8912
+ removed: remove,
8913
+ tags: after?.tags ?? []
8914
+ });
8915
+ })
8916
+ );
8741
8917
  router.post(
8742
8918
  "/contacts/:externalId/reset",
8743
8919
  wrap2(async (req, res) => {
@@ -9238,6 +9414,7 @@ var ENDPOINTS = [
9238
9414
  { method: "POST", path: "/templates/verify-all", summary: "Verify every template (or {slugs}) for each of {contactIds}; a matrix of pass/fail." },
9239
9415
  { method: "POST", path: "/templates/:slug/render", summary: "Render for a contact and return subject, preheader, HTML, plain text, resolved vars and the signed unsubscribe URL." },
9240
9416
  { method: "POST", path: "/templates/:slug/send", summary: 'A real send through the pipeline to a test contact ({contactId}); dispatched inline unless {dispatch: "queue"}. Returns the sendId.', testContactsOnly: true },
9417
+ { method: "PUT", path: "/templates/:slug", summary: "Publish a compiled template document (html, plain text, kind, sender, subject, tracking flags) with the sender-domain and lint gates; upserts on slug, keeping createdAt and stats. The deploy-script path over HTTP." },
9241
9418
  { method: "GET", path: "/sends/:id/wait?status=delivered&timeoutMs=30000", summary: "Long-poll a send until it reaches sent | delivered | opened | clicked | terminal, with its webhook events." },
9242
9419
  { method: "POST", path: "/sends/:id/dispatch", summary: "Dispatch a queued send now (test contacts only).", testContactsOnly: true },
9243
9420
  { method: "POST", path: "/flows/:slug/simulate", summary: "Dry-run the flow for {contactId} from {at} with {eventProperties}: the path taken, every gate verdict, projected send times, where it ends. Writes nothing." },
@@ -9255,6 +9432,7 @@ var ENDPOINTS = [
9255
9432
  { 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 },
9256
9433
  { method: "POST", path: "/contacts/:externalId/subscribe", summary: "Subscribe a test contact.", testContactsOnly: true },
9257
9434
  { method: "POST", path: "/contacts/:externalId/unsubscribe", summary: "Unsubscribe a test contact (marketing scope).", testContactsOnly: true },
9435
+ { 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 },
9258
9436
  { 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 },
9259
9437
  { method: "POST", path: "/tick", summary: "Run the runner tick now (trigger scan, sweeps, outbox, webhook backlog)." },
9260
9438
  { method: "GET", path: "/webhooks/status", summary: "Provider webhook ingest: last event received, counts by type (24h), unprocessed backlog." },
@@ -9971,7 +10149,7 @@ var DEDUPE_POLICIES = [
9971
10149
  ];
9972
10150
 
9973
10151
  // src/server/index.ts
9974
- var VERSION2 = "0.16.4" ;
10152
+ var VERSION2 = "0.16.6" ;
9975
10153
 
9976
10154
  exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
9977
10155
  exports.DEFAULT_BOT_UA_RE = DEFAULT_BOT_UA_RE;