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/admin/spa/{index-BTww-4AA.js → index-l7m1PyMb.js} +2 -2
- package/dist/admin/spa/{index-BTww-4AA.js.map → index-l7m1PyMb.js.map} +1 -1
- package/dist/admin/spa/index.html +1 -1
- package/dist/admin/spa/{template-editor-CrOKLpSw.js → template-editor-pGcCFnJ7.js} +33 -33
- package/dist/admin/spa/{template-editor-CrOKLpSw.js.map → template-editor-pGcCFnJ7.js.map} +1 -1
- package/dist/index.cjs +114 -8
- 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 +114 -8
- package/dist/index.js.map +1 -1
- package/dist/{null-CU_EApC6.d.cts → null-CfzvqNEp.d.cts} +17 -1
- package/dist/{null-CU_EApC6.d.ts → null-CfzvqNEp.d.ts} +17 -1
- package/dist/testing.cjs +11 -4
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +3 -2
- package/dist/testing.d.ts +3 -2
- package/dist/testing.js +11 -4
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
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:
|
|
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
|
|
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
|
-
|
|
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(/ /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
|
-
|
|
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
|
-
|
|
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.
|
|
7720
|
+
var VERSION = "0.12.0" ;
|
|
7615
7721
|
|
|
7616
7722
|
exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
|
|
7617
7723
|
exports.FLOW_STEP_KINDS = FLOW_STEP_KINDS;
|