@emailens/engine 0.3.0 → 0.3.1
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.ts +9 -3
- package/dist/index.js +123 -22
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -171,6 +171,12 @@ interface SpamReport {
|
|
|
171
171
|
level: "low" | "medium" | "high";
|
|
172
172
|
issues: SpamIssue[];
|
|
173
173
|
}
|
|
174
|
+
interface SpamAnalysisOptions {
|
|
175
|
+
/** Value of the List-Unsubscribe header, if present */
|
|
176
|
+
listUnsubscribeHeader?: string;
|
|
177
|
+
/** Type of email — transactional emails are exempt from unsubscribe requirements */
|
|
178
|
+
emailType?: "marketing" | "transactional";
|
|
179
|
+
}
|
|
174
180
|
interface LinkIssue {
|
|
175
181
|
severity: "error" | "warning" | "info";
|
|
176
182
|
rule: string;
|
|
@@ -378,11 +384,11 @@ declare const STRUCTURAL_FIX_PROPERTIES: Set<string>;
|
|
|
378
384
|
/**
|
|
379
385
|
* Analyze an HTML email for spam indicators.
|
|
380
386
|
*
|
|
381
|
-
* Returns a 0
|
|
387
|
+
* Returns a 0-100 score (100 = clean, 0 = very spammy) and an array
|
|
382
388
|
* of issues found. Uses heuristic rules modeled after common spam
|
|
383
389
|
* filter triggers (CAN-SPAM, GDPR, SpamAssassin patterns).
|
|
384
390
|
*/
|
|
385
|
-
declare function analyzeSpam(html: string): SpamReport;
|
|
391
|
+
declare function analyzeSpam(html: string, options?: SpamAnalysisOptions): SpamReport;
|
|
386
392
|
|
|
387
393
|
/**
|
|
388
394
|
* Extract and validate all links from an HTML email.
|
|
@@ -411,4 +417,4 @@ declare function checkAccessibility(html: string): AccessibilityReport;
|
|
|
411
417
|
*/
|
|
412
418
|
declare function analyzeImages(html: string): ImageReport;
|
|
413
419
|
|
|
414
|
-
export { AI_FIX_SYSTEM_PROMPT, type AccessibilityIssue, type AccessibilityReport, type AiFixResult, type AiProvider, type CSSWarning, type CodeFix, type DiffResult, EMAIL_CLIENTS, type EmailClient, type EstimateOptions, type ExportPromptOptions, type ExportScope, type FixType, type Framework, type GenerateAiFixOptions, type ImageInfo, type ImageIssue, type ImageReport, type InputFormat, type LinkIssue, type LinkReport, type PreviewResult, STRUCTURAL_FIX_PROPERTIES, type SpamIssue, type SpamReport, type SupportLevel, type TokenEstimate, type TokenEstimateWithWarnings, type TransformResult, analyzeEmail, analyzeImages, analyzeSpam, checkAccessibility, diffResults, estimateAiFixTokens, generateAiFix, generateCompatibilityScore, generateFixPrompt, getClient, getCodeFix, getSuggestion, heuristicTokenCount, simulateDarkMode, transformForAllClients, transformForClient, validateLinks };
|
|
420
|
+
export { AI_FIX_SYSTEM_PROMPT, type AccessibilityIssue, type AccessibilityReport, type AiFixResult, type AiProvider, type CSSWarning, type CodeFix, type DiffResult, EMAIL_CLIENTS, type EmailClient, type EstimateOptions, type ExportPromptOptions, type ExportScope, type FixType, type Framework, type GenerateAiFixOptions, type ImageInfo, type ImageIssue, type ImageReport, type InputFormat, type LinkIssue, type LinkReport, type PreviewResult, STRUCTURAL_FIX_PROPERTIES, type SpamAnalysisOptions, type SpamIssue, type SpamReport, type SupportLevel, type TokenEstimate, type TokenEstimateWithWarnings, type TransformResult, analyzeEmail, analyzeImages, analyzeSpam, checkAccessibility, diffResults, estimateAiFixTokens, generateAiFix, generateCompatibilityScore, generateFixPrompt, getClient, getCodeFix, getSuggestion, heuristicTokenCount, simulateDarkMode, transformForAllClients, transformForClient, validateLinks };
|
package/dist/index.js
CHANGED
|
@@ -3949,6 +3949,15 @@ var SPAM_TRIGGER_PHRASES = [
|
|
|
3949
3949
|
"no strings attached",
|
|
3950
3950
|
"no questions asked"
|
|
3951
3951
|
];
|
|
3952
|
+
function escapeRegex(s) {
|
|
3953
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3954
|
+
}
|
|
3955
|
+
var SPAM_PHRASE_PATTERNS = new Map(
|
|
3956
|
+
SPAM_TRIGGER_PHRASES.map((phrase) => [
|
|
3957
|
+
phrase,
|
|
3958
|
+
new RegExp("\\b" + escapeRegex(phrase) + "\\b")
|
|
3959
|
+
])
|
|
3960
|
+
);
|
|
3952
3961
|
var URL_SHORTENERS = [
|
|
3953
3962
|
"bit.ly",
|
|
3954
3963
|
"tinyurl.com",
|
|
@@ -3963,6 +3972,46 @@ var URL_SHORTENERS = [
|
|
|
3963
3972
|
"cutt.ly",
|
|
3964
3973
|
"rb.gy"
|
|
3965
3974
|
];
|
|
3975
|
+
var ESP_TRACKING_DOMAINS = [
|
|
3976
|
+
"mailchi.mp",
|
|
3977
|
+
"list-manage.com",
|
|
3978
|
+
"click.mailchimp.com",
|
|
3979
|
+
"sendgrid.net",
|
|
3980
|
+
"click.sendgrid.net",
|
|
3981
|
+
"ct.sendgrid.net",
|
|
3982
|
+
"click.klaviyomail.com",
|
|
3983
|
+
"trk.klaviyo.com",
|
|
3984
|
+
"click.hubspotemail.net",
|
|
3985
|
+
"links.iterable.com",
|
|
3986
|
+
"track.customer.io",
|
|
3987
|
+
"go.pardot.com",
|
|
3988
|
+
"mailgun.org",
|
|
3989
|
+
"em.salesforce.com",
|
|
3990
|
+
"click.marketingcloud.com",
|
|
3991
|
+
"r.mail.yahoo.com",
|
|
3992
|
+
"t.dripemail2.com"
|
|
3993
|
+
];
|
|
3994
|
+
var TRANSACTIONAL_SIGNALS = [
|
|
3995
|
+
"reset your password",
|
|
3996
|
+
"password reset",
|
|
3997
|
+
"verify your email",
|
|
3998
|
+
"email verification",
|
|
3999
|
+
"order confirmation",
|
|
4000
|
+
"your order",
|
|
4001
|
+
"your receipt",
|
|
4002
|
+
"purchase confirmation",
|
|
4003
|
+
"verification code",
|
|
4004
|
+
"confirm your account",
|
|
4005
|
+
"your invoice",
|
|
4006
|
+
"shipping confirmation",
|
|
4007
|
+
"account activation",
|
|
4008
|
+
"security alert"
|
|
4009
|
+
];
|
|
4010
|
+
var TRANSACTIONAL_SIGNAL_PATTERN = new RegExp(
|
|
4011
|
+
TRANSACTIONAL_SIGNALS.map(escapeRegex).join("|"),
|
|
4012
|
+
"i"
|
|
4013
|
+
);
|
|
4014
|
+
var OTP_PATTERN = /\b\d{4,8}\b/;
|
|
3966
4015
|
var WEIGHTS = {
|
|
3967
4016
|
"caps-ratio": 15,
|
|
3968
4017
|
"excessive-punctuation": 10,
|
|
@@ -3997,9 +4046,10 @@ function checkCapsRatio(text) {
|
|
|
3997
4046
|
}
|
|
3998
4047
|
function checkExcessivePunctuation(text) {
|
|
3999
4048
|
const exclamations = (text.match(/!/g) || []).length;
|
|
4000
|
-
const dollars = (text.match(
|
|
4049
|
+
const dollars = (text.match(/\$(?!\d)/g) || []).length;
|
|
4001
4050
|
const total = exclamations + dollars;
|
|
4002
|
-
|
|
4051
|
+
const threshold = Math.max(5, Math.floor(text.length / 200));
|
|
4052
|
+
if (total > threshold) {
|
|
4003
4053
|
return {
|
|
4004
4054
|
rule: "excessive-punctuation",
|
|
4005
4055
|
severity: "warning",
|
|
@@ -4011,8 +4061,8 @@ function checkExcessivePunctuation(text) {
|
|
|
4011
4061
|
function checkSpamPhrases(text) {
|
|
4012
4062
|
const lower = text.toLowerCase();
|
|
4013
4063
|
const found = [];
|
|
4014
|
-
for (const phrase of
|
|
4015
|
-
if (
|
|
4064
|
+
for (const [phrase, pattern] of SPAM_PHRASE_PATTERNS) {
|
|
4065
|
+
if (pattern.test(lower)) {
|
|
4016
4066
|
found.push({
|
|
4017
4067
|
rule: "spam-phrases",
|
|
4018
4068
|
severity: "info",
|
|
@@ -4022,16 +4072,33 @@ function checkSpamPhrases(text) {
|
|
|
4022
4072
|
}
|
|
4023
4073
|
return found;
|
|
4024
4074
|
}
|
|
4025
|
-
function checkUnsubscribe(
|
|
4075
|
+
function checkUnsubscribe($, text, options) {
|
|
4076
|
+
var _a;
|
|
4077
|
+
if ((options == null ? void 0 : options.emailType) === "transactional") return null;
|
|
4078
|
+
if ((_a = options == null ? void 0 : options.listUnsubscribeHeader) == null ? void 0 : _a.trim()) return null;
|
|
4026
4079
|
let hasUnsubscribe = false;
|
|
4027
4080
|
$("a").each((_, el) => {
|
|
4028
4081
|
const href = $(el).attr("href") || "";
|
|
4029
|
-
const
|
|
4030
|
-
if (
|
|
4082
|
+
const linkText = $(el).text().toLowerCase();
|
|
4083
|
+
if (linkText.includes("unsubscribe") || href.toLowerCase().includes("unsubscribe") || linkText.includes("opt out") || linkText.includes("opt-out") || href.toLowerCase().includes("opt-out") || href.toLowerCase().includes("optout")) {
|
|
4031
4084
|
hasUnsubscribe = true;
|
|
4032
4085
|
}
|
|
4033
4086
|
});
|
|
4034
4087
|
if (!hasUnsubscribe) {
|
|
4088
|
+
const lower = text.toLowerCase();
|
|
4089
|
+
const signalMatches = TRANSACTIONAL_SIGNALS.filter(
|
|
4090
|
+
(s) => lower.includes(s.toLowerCase())
|
|
4091
|
+
);
|
|
4092
|
+
const hasOtp = OTP_PATTERN.test(text);
|
|
4093
|
+
const signalCount = signalMatches.length + (hasOtp ? 1 : 0);
|
|
4094
|
+
if (signalCount >= 2) {
|
|
4095
|
+
return {
|
|
4096
|
+
rule: "missing-unsubscribe",
|
|
4097
|
+
severity: "info",
|
|
4098
|
+
message: "No unsubscribe link found, but email appears transactional \u2014 may not be required.",
|
|
4099
|
+
detail: `Detected transactional signals: ${signalMatches.join(", ")}${hasOtp ? ", OTP code" : ""}`
|
|
4100
|
+
};
|
|
4101
|
+
}
|
|
4035
4102
|
return {
|
|
4036
4103
|
rule: "missing-unsubscribe",
|
|
4037
4104
|
severity: "error",
|
|
@@ -4041,6 +4108,18 @@ function checkUnsubscribe($) {
|
|
|
4041
4108
|
}
|
|
4042
4109
|
return null;
|
|
4043
4110
|
}
|
|
4111
|
+
var PREHEADER_ACCESSORY_PATTERNS = [
|
|
4112
|
+
/max-height\s*:\s*0/,
|
|
4113
|
+
/overflow\s*:\s*hidden/,
|
|
4114
|
+
/mso-hide\s*:\s*all/,
|
|
4115
|
+
/opacity\s*:\s*0/,
|
|
4116
|
+
/color\s*:\s*transparent/,
|
|
4117
|
+
/line-height\s*:\s*0/
|
|
4118
|
+
];
|
|
4119
|
+
function isLikelyPreheader(style, text) {
|
|
4120
|
+
if (text.length > 200) return false;
|
|
4121
|
+
return PREHEADER_ACCESSORY_PATTERNS.some((p) => p.test(style));
|
|
4122
|
+
}
|
|
4044
4123
|
function checkHiddenText($) {
|
|
4045
4124
|
let found = false;
|
|
4046
4125
|
let detail = "";
|
|
@@ -4048,20 +4127,24 @@ function checkHiddenText($) {
|
|
|
4048
4127
|
const style = ($(el).attr("style") || "").toLowerCase();
|
|
4049
4128
|
const text = $(el).text().trim();
|
|
4050
4129
|
if (!text) return;
|
|
4051
|
-
if (/font-size\s*:\s*0(?:px|em|rem|pt)?(?:\s|;|$)/.test(style)) {
|
|
4052
|
-
found = true;
|
|
4053
|
-
detail = "font-size:0 on element with text content";
|
|
4054
|
-
return false;
|
|
4055
|
-
}
|
|
4056
4130
|
if (/visibility\s*:\s*hidden/.test(style)) {
|
|
4057
4131
|
found = true;
|
|
4058
4132
|
detail = "visibility:hidden on element with text content";
|
|
4059
4133
|
return false;
|
|
4060
4134
|
}
|
|
4135
|
+
if (/font-size\s*:\s*0(?:px|em|rem|pt)?(?:\s|;|$)/.test(style)) {
|
|
4136
|
+
if (!isLikelyPreheader(style, text)) {
|
|
4137
|
+
found = true;
|
|
4138
|
+
detail = "font-size:0 on element with text content";
|
|
4139
|
+
return false;
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4061
4142
|
if (/display\s*:\s*none/.test(style)) {
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4143
|
+
if (!isLikelyPreheader(style, text)) {
|
|
4144
|
+
found = true;
|
|
4145
|
+
detail = "display:none on element with text content";
|
|
4146
|
+
return false;
|
|
4147
|
+
}
|
|
4065
4148
|
}
|
|
4066
4149
|
});
|
|
4067
4150
|
if (found) {
|
|
@@ -4079,8 +4162,14 @@ function checkUrlShorteners($) {
|
|
|
4079
4162
|
const seen = /* @__PURE__ */ new Set();
|
|
4080
4163
|
$("a[href]").each((_, el) => {
|
|
4081
4164
|
const href = $(el).attr("href") || "";
|
|
4165
|
+
let hostname;
|
|
4166
|
+
try {
|
|
4167
|
+
hostname = new URL(href).hostname.toLowerCase();
|
|
4168
|
+
} catch (e) {
|
|
4169
|
+
return;
|
|
4170
|
+
}
|
|
4082
4171
|
for (const shortener of URL_SHORTENERS) {
|
|
4083
|
-
if (
|
|
4172
|
+
if ((hostname === shortener || hostname.endsWith("." + shortener)) && !seen.has(shortener)) {
|
|
4084
4173
|
seen.add(shortener);
|
|
4085
4174
|
issues.push({
|
|
4086
4175
|
rule: "url-shortener",
|
|
@@ -4093,8 +4182,7 @@ function checkUrlShorteners($) {
|
|
|
4093
4182
|
});
|
|
4094
4183
|
return issues;
|
|
4095
4184
|
}
|
|
4096
|
-
function checkImageToTextRatio(
|
|
4097
|
-
const text = extractVisibleText($);
|
|
4185
|
+
function checkImageToTextRatio($, text) {
|
|
4098
4186
|
const images = $("img").length;
|
|
4099
4187
|
if (images === 0) return null;
|
|
4100
4188
|
if (text.length < 50 && images > 0) {
|
|
@@ -4114,6 +4202,15 @@ function checkImageToTextRatio($) {
|
|
|
4114
4202
|
}
|
|
4115
4203
|
return null;
|
|
4116
4204
|
}
|
|
4205
|
+
function isEspTrackingDomain(hostname) {
|
|
4206
|
+
return ESP_TRACKING_DOMAINS.some(
|
|
4207
|
+
(esp) => hostname === esp || hostname.endsWith("." + esp)
|
|
4208
|
+
);
|
|
4209
|
+
}
|
|
4210
|
+
function hasEncodedDestination(href, textDomain) {
|
|
4211
|
+
const encoded = encodeURIComponent(textDomain);
|
|
4212
|
+
return href.includes(encoded) || href.includes(textDomain);
|
|
4213
|
+
}
|
|
4117
4214
|
function checkDeceptiveLinks($) {
|
|
4118
4215
|
const issues = [];
|
|
4119
4216
|
$("a[href]").each((_, el) => {
|
|
@@ -4126,6 +4223,8 @@ function checkDeceptiveLinks($) {
|
|
|
4126
4223
|
).hostname.replace(/^www\./, "");
|
|
4127
4224
|
const hrefDomain = new URL(href).hostname.replace(/^www\./, "");
|
|
4128
4225
|
if (textDomain !== hrefDomain) {
|
|
4226
|
+
if (isEspTrackingDomain(hrefDomain)) return;
|
|
4227
|
+
if (hasEncodedDestination(href, textDomain)) return;
|
|
4129
4228
|
issues.push({
|
|
4130
4229
|
rule: "deceptive-link",
|
|
4131
4230
|
severity: "error",
|
|
@@ -4151,7 +4250,7 @@ function checkAllCapsTitle($) {
|
|
|
4151
4250
|
}
|
|
4152
4251
|
return null;
|
|
4153
4252
|
}
|
|
4154
|
-
function analyzeSpam(html) {
|
|
4253
|
+
function analyzeSpam(html, options) {
|
|
4155
4254
|
if (!html || !html.trim()) {
|
|
4156
4255
|
return { score: 100, level: "low", issues: [] };
|
|
4157
4256
|
}
|
|
@@ -4163,12 +4262,12 @@ function analyzeSpam(html) {
|
|
|
4163
4262
|
const punctIssue = checkExcessivePunctuation(text);
|
|
4164
4263
|
if (punctIssue) issues.push(punctIssue);
|
|
4165
4264
|
issues.push(...checkSpamPhrases(text));
|
|
4166
|
-
const unsubIssue = checkUnsubscribe(
|
|
4265
|
+
const unsubIssue = checkUnsubscribe($, text, options);
|
|
4167
4266
|
if (unsubIssue) issues.push(unsubIssue);
|
|
4168
4267
|
const hiddenIssue = checkHiddenText($);
|
|
4169
4268
|
if (hiddenIssue) issues.push(hiddenIssue);
|
|
4170
4269
|
issues.push(...checkUrlShorteners($));
|
|
4171
|
-
const imageRatioIssue = checkImageToTextRatio(
|
|
4270
|
+
const imageRatioIssue = checkImageToTextRatio($, text);
|
|
4172
4271
|
if (imageRatioIssue) issues.push(imageRatioIssue);
|
|
4173
4272
|
issues.push(...checkDeceptiveLinks($));
|
|
4174
4273
|
const capsTitle = checkAllCapsTitle($);
|
|
@@ -4184,7 +4283,9 @@ function analyzeSpam(html) {
|
|
|
4184
4283
|
} else if (issue.rule === "url-shortener" || issue.rule === "deceptive-link") {
|
|
4185
4284
|
if (count <= 2) penalty += weight;
|
|
4186
4285
|
} else {
|
|
4187
|
-
|
|
4286
|
+
if (issue.severity !== "info") {
|
|
4287
|
+
penalty += weight;
|
|
4288
|
+
}
|
|
4188
4289
|
}
|
|
4189
4290
|
}
|
|
4190
4291
|
const score = Math.max(0, Math.min(100, 100 - penalty));
|