@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 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 0100 score (100 = clean, 0 = very spammy) and an array
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(/\$/g) || []).length;
4049
+ const dollars = (text.match(/\$(?!\d)/g) || []).length;
4001
4050
  const total = exclamations + dollars;
4002
- if (total > 5) {
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 SPAM_TRIGGER_PHRASES) {
4015
- if (lower.includes(phrase)) {
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 text = $(el).text().toLowerCase();
4030
- if (text.includes("unsubscribe") || href.toLowerCase().includes("unsubscribe") || text.includes("opt out") || text.includes("opt-out") || href.toLowerCase().includes("opt-out") || href.toLowerCase().includes("optout")) {
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
- found = true;
4063
- detail = "display:none on element with text content";
4064
- return false;
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 (href.includes(shortener) && !seen.has(shortener)) {
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
- penalty += weight;
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));