mailery 0.10.2 → 0.12.0

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
@@ -322,7 +322,10 @@ var init_sendgrid = __esm({
322
322
  replyTo: args.replyTo,
323
323
  subject: args.subject,
324
324
  text: args.text,
325
- html: args.html,
325
+ // Omit the key entirely rather than sending `html: undefined` — a
326
+ // text_only template must produce a single-part text/plain message, and
327
+ // an explicit undefined can still serialize into an empty HTML part.
328
+ ...args.html ? { html: args.html } : {},
326
329
  headers: args.headers,
327
330
  customArgs: args.messageMeta,
328
331
  trackingSettings: {
@@ -2161,7 +2164,8 @@ async function dispatchSend(sendId, ctx) {
2161
2164
  await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
2162
2165
  throw err;
2163
2166
  }
2164
- const tracking = applyTracking(rendered.html, {
2167
+ const textOnly = template.bodyFormat === "text_only";
2168
+ const tracking = textOnly ? { html: "", links: [] } : applyTracking(rendered.html, {
2165
2169
  sendId: String(send._id),
2166
2170
  publicUrl: ctx.config.publicUrl,
2167
2171
  trackOpens: template.trackOpens ?? ctx.config.trackOpens,
@@ -2173,7 +2177,9 @@ async function dispatchSend(sendId, ctx) {
2173
2177
  {
2174
2178
  $set: {
2175
2179
  links: tracking.links,
2176
- bodyHash: sha256(tracking.html),
2180
+ // Hash what actually goes out, so a text_only send's fingerprint
2181
+ // tracks the text body rather than an HTML part it never had.
2182
+ bodyHash: sha256(textOnly ? rendered.plainText : tracking.html),
2177
2183
  status: "sending",
2178
2184
  fromName: rendered.fromName,
2179
2185
  fromEmail: rendered.fromEmail,
@@ -2198,7 +2204,7 @@ async function dispatchSend(sendId, ctx) {
2198
2204
  fromEmail: rendered.fromEmail,
2199
2205
  replyTo: rendered.replyTo ?? void 0,
2200
2206
  subject: rendered.subject,
2201
- html: tracking.html,
2207
+ ...textOnly ? {} : { html: tracking.html },
2202
2208
  text: rendered.plainText,
2203
2209
  headers,
2204
2210
  messageMeta: { sendId: String(send._id) }
@@ -4703,6 +4709,40 @@ ${input.plainText}`);
4703
4709
  });
4704
4710
  }
4705
4711
  }
4712
+ const offDomain = findOffDomainLinkHosts(input.html, input.fromEmail);
4713
+ if (offDomain.majority && offDomain.hosts.length > 0) {
4714
+ issues.push({
4715
+ rule: "offdomain_links",
4716
+ severity: "warning",
4717
+ message: `Most links point away from the From domain: ${offDomain.hosts.join(", ")}.`,
4718
+ hint: "Link domains that match the sending domain build reputation. Route links through your own domain (or a tracking subdomain of it) where you can."
4719
+ });
4720
+ }
4721
+ const insecure = findInsecureLinkHosts(input.html);
4722
+ if (insecure.length > 0) {
4723
+ issues.push({
4724
+ rule: "insecure_link",
4725
+ severity: "warning",
4726
+ message: `Link uses plain http://: ${insecure.join(", ")}.`,
4727
+ hint: "Mixed-content links get rewritten or warned about by some clients, and http:// correlates with stale spam templates. Use https://."
4728
+ });
4729
+ }
4730
+ if (countImagesMissingAlt(input.html) > 0) {
4731
+ issues.push({
4732
+ rule: "image_missing_alt",
4733
+ severity: "warning",
4734
+ message: "One or more images have no alt text.",
4735
+ hint: "Most clients block images by default on first open. Alt text is what the recipient actually sees, and screen readers need it."
4736
+ });
4737
+ }
4738
+ if (hasImageOnlyLink(input.html)) {
4739
+ issues.push({
4740
+ rule: "image_only_link",
4741
+ severity: "warning",
4742
+ message: "A link wraps an image with no accompanying text.",
4743
+ hint: "With images blocked, an image-only call-to-action is invisible and unclickable. Add a text label inside the link."
4744
+ });
4745
+ }
4706
4746
  const linkCount = countMatches(input.html, /<a\s[^>]*\bhref\s*=/gi);
4707
4747
  if (linkCount > 10) {
4708
4748
  issues.push({
@@ -4757,6 +4797,63 @@ function hasBareUrlInVisibleText(html) {
4757
4797
  const stripped = html.replace(/<a\b[^>]*>.*?<\/a>/gis, " ").replace(/<style\b[^>]*>.*?<\/style>/gis, " ").replace(/<script\b[^>]*>.*?<\/script>/gis, " ").replace(/<head\b[^>]*>.*?<\/head>/gis, " ");
4758
4798
  return /https?:\/\/[^\s<>"']+/i.test(stripped);
4759
4799
  }
4800
+ function findOffDomainLinkHosts(html, fromEmail) {
4801
+ const fromDomain = fromEmail.split("@")[1]?.toLowerCase();
4802
+ if (!fromDomain) return { hosts: [], majority: false };
4803
+ const hosts = /* @__PURE__ */ new Set();
4804
+ let total = 0;
4805
+ let off = 0;
4806
+ for (const href of extractHrefs(html)) {
4807
+ if (href.includes("{{")) continue;
4808
+ const host = hostnameOf(href);
4809
+ if (!host) continue;
4810
+ total++;
4811
+ if (sameSite(host, fromDomain)) continue;
4812
+ off++;
4813
+ hosts.add(host);
4814
+ }
4815
+ return { hosts: Array.from(hosts), majority: total > 0 && off * 2 > total };
4816
+ }
4817
+ function sameSite(a, b) {
4818
+ if (a === b) return true;
4819
+ if (a.endsWith(`.${b}`) || b.endsWith(`.${a}`)) return true;
4820
+ return lastLabels(a) === lastLabels(b);
4821
+ }
4822
+ function lastLabels(host) {
4823
+ return host.split(".").slice(-2).join(".");
4824
+ }
4825
+ function findInsecureLinkHosts(html) {
4826
+ const hits = /* @__PURE__ */ new Set();
4827
+ for (const href of extractHrefs(html)) {
4828
+ if (!/^http:\/\//i.test(href)) continue;
4829
+ const host = hostnameOf(href);
4830
+ if (host) hits.add(host);
4831
+ }
4832
+ return Array.from(hits);
4833
+ }
4834
+ function countImagesMissingAlt(html) {
4835
+ let missing = 0;
4836
+ const re = /<img\b[^>]*>/gi;
4837
+ let m;
4838
+ while (m = re.exec(html)) {
4839
+ const tag = m[0];
4840
+ const alt = /\balt\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(tag);
4841
+ const value = alt ? alt[1] ?? alt[2] ?? alt[3] ?? "" : "";
4842
+ if (value.trim().length === 0) missing++;
4843
+ }
4844
+ return missing;
4845
+ }
4846
+ function hasImageOnlyLink(html) {
4847
+ const re = /<a\b[^>]*>(.*?)<\/a>/gis;
4848
+ let m;
4849
+ while (m = re.exec(html)) {
4850
+ const inner = m[1];
4851
+ if (!/<img\b/i.test(inner)) continue;
4852
+ const text = inner.replace(/<[^>]*>/g, "").replace(/&nbsp;/gi, " ").trim();
4853
+ if (text.length === 0) return true;
4854
+ }
4855
+ return false;
4856
+ }
4760
4857
  function findSpamSignals(text) {
4761
4858
  const out = /* @__PURE__ */ new Set();
4762
4859
  for (const { pattern, phrase } of SPAM_PHRASES) {
@@ -4829,6 +4926,9 @@ function isAllCaps(subject) {
4829
4926
  // src/server/api/admin.ts
4830
4927
  init_vars();
4831
4928
 
4929
+ // src/shared/enums.ts
4930
+ var TEMPLATE_BODY_FORMATS = ["multipart", "text_only"];
4931
+
4832
4932
  // src/server/api/setup-status.ts
4833
4933
  async function runSetupChecks(mailer) {
4834
4934
  const checks = [];
@@ -6488,6 +6588,7 @@ function apiRouter(mailer, opts = {}) {
6488
6588
  lastModifiedAt: now
6489
6589
  },
6490
6590
  tags: [],
6591
+ bodyFormat: "multipart",
6491
6592
  trackOpens: kind === "marketing",
6492
6593
  trackClicks: kind === "marketing",
6493
6594
  stats: { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0, lastSentAt: null },
@@ -6513,7 +6614,7 @@ function apiRouter(mailer, opts = {}) {
6513
6614
  asyncHandler(async (req, res) => {
6514
6615
  const tpl = await c.templates.findOne({ slug: req.params.slug });
6515
6616
  if (!tpl) return res.status(404).json({ error: "not_found" });
6516
- const { subject, preheader, mjml, editorJson, notes, name, fromName, fromEmail, replyTo, kind, trackOpens, trackClicks } = req.body ?? {};
6617
+ const { subject, preheader, mjml, editorJson, notes, name, fromName, fromEmail, replyTo, kind, bodyFormat, trackOpens, trackClicks } = req.body ?? {};
6517
6618
  const set = {
6518
6619
  "draft.lastModifiedBy": req.actor,
6519
6620
  "draft.lastModifiedAt": /* @__PURE__ */ new Date(),
@@ -6541,6 +6642,7 @@ function apiRouter(mailer, opts = {}) {
6541
6642
  });
6542
6643
  }
6543
6644
  }
6645
+ if (TEMPLATE_BODY_FORMATS.includes(bodyFormat)) set.bodyFormat = bodyFormat;
6544
6646
  if (typeof trackOpens === "boolean") set.trackOpens = trackOpens;
6545
6647
  if (typeof trackClicks === "boolean") set.trackClicks = trackClicks;
6546
6648
  await c.templates.updateOne({ _id: tpl._id }, { $set: set });
@@ -6626,7 +6728,9 @@ function apiRouter(mailer, opts = {}) {
6626
6728
  fromEmail: tpl.fromEmail,
6627
6729
  replyTo: tpl.replyTo ?? void 0,
6628
6730
  subject: draft.subject,
6629
- html: compiled.html,
6731
+ // Match the real send shape — a text_only template's deliverability
6732
+ // should be scored on the single-part message it actually sends.
6733
+ ...tpl.bodyFormat === "text_only" ? {} : { html: compiled.html },
6630
6734
  text: compiled.plainText,
6631
6735
  headers: {},
6632
6736
  messageMeta: { mailTesterCheckId: checkId }
@@ -6971,7 +7075,9 @@ function apiRouter(mailer, opts = {}) {
6971
7075
  fromName: rendered.fromName,
6972
7076
  fromEmail: rendered.fromEmail,
6973
7077
  subject: `[TEST] ${rendered.subject}`,
6974
- html: tracking.html,
7078
+ // Match the real send shape, or a test of a text_only template would
7079
+ // arrive as HTML and hide exactly what the author is checking.
7080
+ ...tpl.bodyFormat === "text_only" ? {} : { html: tracking.html },
6975
7081
  text: rendered.plainText
6976
7082
  });
6977
7083
  await mailer.audit({
@@ -7611,7 +7717,7 @@ var DEDUPE_POLICIES = [
7611
7717
  ];
7612
7718
 
7613
7719
  // src/server/index.ts
7614
- var VERSION = "0.10.2" ;
7720
+ var VERSION = "0.12.0" ;
7615
7721
 
7616
7722
  exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
7617
7723
  exports.FLOW_STEP_KINDS = FLOW_STEP_KINDS;