@emailens/engine 0.2.0 → 0.3.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.js CHANGED
@@ -3909,11 +3909,836 @@ function extractCode(response) {
3909
3909
  }
3910
3910
  return response.trim();
3911
3911
  }
3912
+
3913
+ // src/spam-scorer.ts
3914
+ import * as cheerio4 from "cheerio";
3915
+ var SPAM_TRIGGER_PHRASES = [
3916
+ "act now",
3917
+ "limited time",
3918
+ "click here",
3919
+ "buy now",
3920
+ "order now",
3921
+ "don't miss",
3922
+ "don't delete",
3923
+ "urgent",
3924
+ "congratulations",
3925
+ "you've been selected",
3926
+ "you've won",
3927
+ "winner",
3928
+ "free gift",
3929
+ "risk free",
3930
+ "no obligation",
3931
+ "no cost",
3932
+ "no fees",
3933
+ "100% free",
3934
+ "100% satisfied",
3935
+ "double your money",
3936
+ "earn extra cash",
3937
+ "make money",
3938
+ "cash bonus",
3939
+ "as seen on",
3940
+ "incredible deal",
3941
+ "lowest price",
3942
+ "once in a lifetime",
3943
+ "special promotion",
3944
+ "this isn't spam",
3945
+ "what are you waiting for",
3946
+ "apply now",
3947
+ "sign up free",
3948
+ "cancel anytime",
3949
+ "no strings attached",
3950
+ "no questions asked"
3951
+ ];
3952
+ var URL_SHORTENERS = [
3953
+ "bit.ly",
3954
+ "tinyurl.com",
3955
+ "t.co",
3956
+ "goo.gl",
3957
+ "ow.ly",
3958
+ "is.gd",
3959
+ "buff.ly",
3960
+ "rebrand.ly",
3961
+ "bl.ink",
3962
+ "short.io",
3963
+ "cutt.ly",
3964
+ "rb.gy"
3965
+ ];
3966
+ var WEIGHTS = {
3967
+ "caps-ratio": 15,
3968
+ "excessive-punctuation": 10,
3969
+ "spam-phrases": 5,
3970
+ "missing-unsubscribe": 15,
3971
+ "hidden-text": 20,
3972
+ "url-shortener": 10,
3973
+ "image-only": 20,
3974
+ "high-image-ratio": 10,
3975
+ "deceptive-link": 15,
3976
+ "all-caps-subject": 10
3977
+ };
3978
+ function extractVisibleText($) {
3979
+ const clone = $.root().clone();
3980
+ clone.find("script, style, head").remove();
3981
+ return clone.text().replace(/\s+/g, " ").trim();
3982
+ }
3983
+ function checkCapsRatio(text) {
3984
+ const words = text.split(/\s+/).filter((w) => w.length >= 3);
3985
+ if (words.length < 5) return null;
3986
+ const capsWords = words.filter((w) => w === w.toUpperCase() && /[A-Z]/.test(w));
3987
+ const ratio = capsWords.length / words.length;
3988
+ if (ratio > 0.2) {
3989
+ return {
3990
+ rule: "caps-ratio",
3991
+ severity: "warning",
3992
+ message: `${Math.round(ratio * 100)}% of words are ALL CAPS \u2014 spam filters flag excessive capitalization.`,
3993
+ detail: `Found ${capsWords.length} of ${words.length} words in all caps.`
3994
+ };
3995
+ }
3996
+ return null;
3997
+ }
3998
+ function checkExcessivePunctuation(text) {
3999
+ const exclamations = (text.match(/!/g) || []).length;
4000
+ const dollars = (text.match(/\$/g) || []).length;
4001
+ const total = exclamations + dollars;
4002
+ if (total > 5) {
4003
+ return {
4004
+ rule: "excessive-punctuation",
4005
+ severity: "warning",
4006
+ message: `Excessive special characters detected (${exclamations} "!", ${dollars} "$") \u2014 common spam trigger.`
4007
+ };
4008
+ }
4009
+ return null;
4010
+ }
4011
+ function checkSpamPhrases(text) {
4012
+ const lower = text.toLowerCase();
4013
+ const found = [];
4014
+ for (const phrase of SPAM_TRIGGER_PHRASES) {
4015
+ if (lower.includes(phrase)) {
4016
+ found.push({
4017
+ rule: "spam-phrases",
4018
+ severity: "info",
4019
+ message: `Contains spam trigger phrase: "${phrase}"`
4020
+ });
4021
+ }
4022
+ }
4023
+ return found;
4024
+ }
4025
+ function checkUnsubscribe($) {
4026
+ let hasUnsubscribe = false;
4027
+ $("a").each((_, el) => {
4028
+ 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")) {
4031
+ hasUnsubscribe = true;
4032
+ }
4033
+ });
4034
+ if (!hasUnsubscribe) {
4035
+ return {
4036
+ rule: "missing-unsubscribe",
4037
+ severity: "error",
4038
+ message: "No unsubscribe link found \u2014 required by CAN-SPAM and GDPR. Most spam filters penalize this.",
4039
+ detail: 'Add an <a> link with "unsubscribe" text or href.'
4040
+ };
4041
+ }
4042
+ return null;
4043
+ }
4044
+ function checkHiddenText($) {
4045
+ let found = false;
4046
+ let detail = "";
4047
+ $("[style]").each((_, el) => {
4048
+ const style = ($(el).attr("style") || "").toLowerCase();
4049
+ const text = $(el).text().trim();
4050
+ 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
+ if (/visibility\s*:\s*hidden/.test(style)) {
4057
+ found = true;
4058
+ detail = "visibility:hidden on element with text content";
4059
+ return false;
4060
+ }
4061
+ if (/display\s*:\s*none/.test(style)) {
4062
+ found = true;
4063
+ detail = "display:none on element with text content";
4064
+ return false;
4065
+ }
4066
+ });
4067
+ if (found) {
4068
+ return {
4069
+ rule: "hidden-text",
4070
+ severity: "error",
4071
+ message: "Hidden text detected \u2014 major spam filter red flag.",
4072
+ detail
4073
+ };
4074
+ }
4075
+ return null;
4076
+ }
4077
+ function checkUrlShorteners($) {
4078
+ const issues = [];
4079
+ const seen = /* @__PURE__ */ new Set();
4080
+ $("a[href]").each((_, el) => {
4081
+ const href = $(el).attr("href") || "";
4082
+ for (const shortener of URL_SHORTENERS) {
4083
+ if (href.includes(shortener) && !seen.has(shortener)) {
4084
+ seen.add(shortener);
4085
+ issues.push({
4086
+ rule: "url-shortener",
4087
+ severity: "warning",
4088
+ message: `URL shortener detected (${shortener}) \u2014 spam filters distrust shortened links.`,
4089
+ detail: href
4090
+ });
4091
+ }
4092
+ }
4093
+ });
4094
+ return issues;
4095
+ }
4096
+ function checkImageToTextRatio($) {
4097
+ const text = extractVisibleText($);
4098
+ const images = $("img").length;
4099
+ if (images === 0) return null;
4100
+ if (text.length < 50 && images > 0) {
4101
+ return {
4102
+ rule: "image-only",
4103
+ severity: "error",
4104
+ message: `Image-heavy email with almost no text (${text.length} chars, ${images} images) \u2014 likely to be flagged as spam or clipped.`
4105
+ };
4106
+ }
4107
+ const ratio = images / (text.length / 100);
4108
+ if (ratio > 0.5 && images > 3) {
4109
+ return {
4110
+ rule: "high-image-ratio",
4111
+ severity: "warning",
4112
+ message: `High image-to-text ratio (${images} images for ${text.length} chars of text) \u2014 consider adding more text content.`
4113
+ };
4114
+ }
4115
+ return null;
4116
+ }
4117
+ function checkDeceptiveLinks($) {
4118
+ const issues = [];
4119
+ $("a[href]").each((_, el) => {
4120
+ const href = $(el).attr("href") || "";
4121
+ const text = $(el).text().trim();
4122
+ if (/^https?:\/\/\S+/i.test(text) || /^www\.\S+/i.test(text)) {
4123
+ try {
4124
+ const textDomain = new URL(
4125
+ text.startsWith("www.") ? `https://${text}` : text
4126
+ ).hostname.replace(/^www\./, "");
4127
+ const hrefDomain = new URL(href).hostname.replace(/^www\./, "");
4128
+ if (textDomain !== hrefDomain) {
4129
+ issues.push({
4130
+ rule: "deceptive-link",
4131
+ severity: "error",
4132
+ message: `Link text shows "${textDomain}" but links to "${hrefDomain}" \u2014 phishing red flag.`,
4133
+ detail: `Text: ${text}
4134
+ Href: ${href}`
4135
+ });
4136
+ }
4137
+ } catch (e) {
4138
+ }
4139
+ }
4140
+ });
4141
+ return issues;
4142
+ }
4143
+ function checkAllCapsTitle($) {
4144
+ const title = $("title").text().trim();
4145
+ if (title.length > 5 && title === title.toUpperCase() && /[A-Z]/.test(title)) {
4146
+ return {
4147
+ rule: "all-caps-subject",
4148
+ severity: "warning",
4149
+ message: "Email title/subject is ALL CAPS \u2014 common spam indicator."
4150
+ };
4151
+ }
4152
+ return null;
4153
+ }
4154
+ function analyzeSpam(html) {
4155
+ if (!html || !html.trim()) {
4156
+ return { score: 100, level: "low", issues: [] };
4157
+ }
4158
+ const $ = cheerio4.load(html);
4159
+ const text = extractVisibleText($);
4160
+ const issues = [];
4161
+ const capsIssue = checkCapsRatio(text);
4162
+ if (capsIssue) issues.push(capsIssue);
4163
+ const punctIssue = checkExcessivePunctuation(text);
4164
+ if (punctIssue) issues.push(punctIssue);
4165
+ issues.push(...checkSpamPhrases(text));
4166
+ const unsubIssue = checkUnsubscribe($);
4167
+ if (unsubIssue) issues.push(unsubIssue);
4168
+ const hiddenIssue = checkHiddenText($);
4169
+ if (hiddenIssue) issues.push(hiddenIssue);
4170
+ issues.push(...checkUrlShorteners($));
4171
+ const imageRatioIssue = checkImageToTextRatio($);
4172
+ if (imageRatioIssue) issues.push(imageRatioIssue);
4173
+ issues.push(...checkDeceptiveLinks($));
4174
+ const capsTitle = checkAllCapsTitle($);
4175
+ if (capsTitle) issues.push(capsTitle);
4176
+ let penalty = 0;
4177
+ const seenRules = /* @__PURE__ */ new Map();
4178
+ for (const issue of issues) {
4179
+ const count = (seenRules.get(issue.rule) || 0) + 1;
4180
+ seenRules.set(issue.rule, count);
4181
+ const weight = WEIGHTS[issue.rule] || 5;
4182
+ if (issue.rule === "spam-phrases") {
4183
+ if (count <= 5) penalty += weight;
4184
+ } else if (issue.rule === "url-shortener" || issue.rule === "deceptive-link") {
4185
+ if (count <= 2) penalty += weight;
4186
+ } else {
4187
+ penalty += weight;
4188
+ }
4189
+ }
4190
+ const score = Math.max(0, Math.min(100, 100 - penalty));
4191
+ const level = score >= 70 ? "low" : score >= 40 ? "medium" : "high";
4192
+ return { score, level, issues };
4193
+ }
4194
+
4195
+ // src/link-validator.ts
4196
+ import * as cheerio5 from "cheerio";
4197
+ var GENERIC_LINK_TEXT = /* @__PURE__ */ new Set([
4198
+ "click here",
4199
+ "here",
4200
+ "read more",
4201
+ "learn more",
4202
+ "more",
4203
+ "link",
4204
+ "this link",
4205
+ "click",
4206
+ "tap here",
4207
+ "this"
4208
+ ]);
4209
+ function classifyHref(href) {
4210
+ if (!href || !href.trim()) return "empty";
4211
+ const h = href.trim().toLowerCase();
4212
+ if (h.startsWith("https://")) return "https";
4213
+ if (h.startsWith("http://")) return "http";
4214
+ if (h.startsWith("mailto:")) return "mailto";
4215
+ if (h.startsWith("tel:")) return "tel";
4216
+ if (h.startsWith("#")) return "anchor";
4217
+ if (h.startsWith("javascript:")) return "javascript";
4218
+ if (h.startsWith("//")) return "protocol-relative";
4219
+ return "other";
4220
+ }
4221
+ function isPlaceholderHref(href) {
4222
+ const h = href.trim().toLowerCase();
4223
+ return h === "#" || h === "" || h === "javascript:void(0)" || h === "javascript:;";
4224
+ }
4225
+ function validateLinks(html) {
4226
+ if (!html || !html.trim()) {
4227
+ return {
4228
+ totalLinks: 0,
4229
+ issues: [],
4230
+ breakdown: { https: 0, http: 0, mailto: 0, tel: 0, anchor: 0, other: 0 }
4231
+ };
4232
+ }
4233
+ const $ = cheerio5.load(html);
4234
+ const issues = [];
4235
+ const breakdown = { https: 0, http: 0, mailto: 0, tel: 0, anchor: 0, other: 0 };
4236
+ const links = $("a");
4237
+ const totalLinks = links.length;
4238
+ if (totalLinks === 0) {
4239
+ issues.push({
4240
+ severity: "info",
4241
+ rule: "no-links",
4242
+ message: "Email contains no links"
4243
+ });
4244
+ return { totalLinks: 0, issues, breakdown };
4245
+ }
4246
+ links.each((_, el) => {
4247
+ const href = $(el).attr("href") || "";
4248
+ const text = $(el).text().trim();
4249
+ const category = classifyHref(href);
4250
+ switch (category) {
4251
+ case "https":
4252
+ breakdown.https++;
4253
+ break;
4254
+ case "http":
4255
+ breakdown.http++;
4256
+ break;
4257
+ case "mailto":
4258
+ breakdown.mailto++;
4259
+ break;
4260
+ case "tel":
4261
+ breakdown.tel++;
4262
+ break;
4263
+ case "anchor":
4264
+ breakdown.anchor++;
4265
+ break;
4266
+ default:
4267
+ breakdown.other++;
4268
+ break;
4269
+ }
4270
+ if (!href || !href.trim()) {
4271
+ issues.push({
4272
+ severity: "error",
4273
+ rule: "empty-href",
4274
+ message: "Link has no href attribute",
4275
+ text: text.slice(0, 80) || "(no text)"
4276
+ });
4277
+ return;
4278
+ }
4279
+ if (category === "javascript" && !isPlaceholderHref(href)) {
4280
+ issues.push({
4281
+ severity: "error",
4282
+ rule: "javascript-href",
4283
+ message: "Link uses javascript: protocol",
4284
+ href: href.slice(0, 100),
4285
+ text: text.slice(0, 80) || "(no text)"
4286
+ });
4287
+ return;
4288
+ }
4289
+ if (isPlaceholderHref(href)) {
4290
+ issues.push({
4291
+ severity: "warning",
4292
+ rule: "placeholder-href",
4293
+ message: "Link has a placeholder href (# or javascript:void)",
4294
+ href,
4295
+ text: text.slice(0, 80) || "(no text)"
4296
+ });
4297
+ return;
4298
+ }
4299
+ if (category === "http") {
4300
+ issues.push({
4301
+ severity: "warning",
4302
+ rule: "insecure-link",
4303
+ message: "Link uses HTTP instead of HTTPS",
4304
+ href: href.slice(0, 120),
4305
+ text: text.slice(0, 80) || "(no text)"
4306
+ });
4307
+ }
4308
+ if (text && GENERIC_LINK_TEXT.has(text.toLowerCase())) {
4309
+ issues.push({
4310
+ severity: "warning",
4311
+ rule: "generic-link-text",
4312
+ message: `Link text "${text}" is vague \u2014 use descriptive text for accessibility and engagement`,
4313
+ href: href.slice(0, 120),
4314
+ text
4315
+ });
4316
+ }
4317
+ if (!text && !$(el).attr("aria-label") && !$(el).find("img[alt]").length) {
4318
+ issues.push({
4319
+ severity: "error",
4320
+ rule: "empty-link-text",
4321
+ message: "Link has no visible text or aria-label",
4322
+ href: href.slice(0, 120)
4323
+ });
4324
+ }
4325
+ if (category === "mailto" && href.trim().toLowerCase() === "mailto:") {
4326
+ issues.push({
4327
+ severity: "error",
4328
+ rule: "empty-mailto",
4329
+ message: "mailto: link has no email address",
4330
+ href,
4331
+ text: text.slice(0, 80) || "(no text)"
4332
+ });
4333
+ }
4334
+ if (href.length > 2e3) {
4335
+ issues.push({
4336
+ severity: "info",
4337
+ rule: "long-url",
4338
+ message: "URL exceeds 2000 characters \u2014 may be truncated by some email clients",
4339
+ href: href.slice(0, 120) + "...",
4340
+ text: text.slice(0, 80) || "(no text)"
4341
+ });
4342
+ }
4343
+ });
4344
+ return { totalLinks, issues, breakdown };
4345
+ }
4346
+
4347
+ // src/accessibility-checker.ts
4348
+ import * as cheerio6 from "cheerio";
4349
+ var GENERIC_LINK_TEXT2 = /* @__PURE__ */ new Set([
4350
+ "click here",
4351
+ "here",
4352
+ "read more",
4353
+ "learn more",
4354
+ "more",
4355
+ "link",
4356
+ "this link",
4357
+ "click",
4358
+ "tap here",
4359
+ "this"
4360
+ ]);
4361
+ function describeElement($, el) {
4362
+ var _a;
4363
+ const tag = ((_a = el.tagName) == null ? void 0 : _a.toLowerCase()) || "unknown";
4364
+ const src = $(el).attr("src");
4365
+ const href = $(el).attr("href");
4366
+ if (src) return `<${tag} src="${src.slice(0, 60)}${src.length > 60 ? "..." : ""}">`;
4367
+ if (href) return `<${tag} href="${href.slice(0, 60)}${href.length > 60 ? "..." : ""}">`;
4368
+ const text = $(el).text().trim().slice(0, 40);
4369
+ if (text) return `<${tag}>${text}${$(el).text().trim().length > 40 ? "..." : ""}</${tag}>`;
4370
+ return `<${tag}>`;
4371
+ }
4372
+ function checkLangAttribute($) {
4373
+ const lang = $("html").attr("lang");
4374
+ if (!lang || !lang.trim()) {
4375
+ return {
4376
+ severity: "error",
4377
+ rule: "missing-lang",
4378
+ message: "Missing lang attribute on <html> element",
4379
+ details: 'Screen readers use the lang attribute to determine pronunciation. Add lang="en" (or appropriate language code).'
4380
+ };
4381
+ }
4382
+ return null;
4383
+ }
4384
+ function checkTitle($) {
4385
+ const title = $("title").text().trim();
4386
+ if (!title) {
4387
+ return {
4388
+ severity: "warning",
4389
+ rule: "missing-title",
4390
+ message: "Missing or empty <title> element",
4391
+ details: "The <title> helps screen readers identify the email content."
4392
+ };
4393
+ }
4394
+ return null;
4395
+ }
4396
+ function checkImageAlt($) {
4397
+ const issues = [];
4398
+ $("img").each((_, el) => {
4399
+ const alt = $(el).attr("alt");
4400
+ const src = $(el).attr("src") || "";
4401
+ const role = $(el).attr("role");
4402
+ if (role === "presentation" || role === "none") return;
4403
+ if (alt === void 0) {
4404
+ issues.push({
4405
+ severity: "error",
4406
+ rule: "img-missing-alt",
4407
+ message: "Image missing alt attribute",
4408
+ element: describeElement($, el),
4409
+ details: 'Every image must have an alt attribute. Use alt="" for decorative images.'
4410
+ });
4411
+ } else if (alt.trim() === "") {
4412
+ const isLikelyContent = !src.includes("spacer") && !src.includes("pixel") && !src.includes("tracking") && !src.includes("1x1") && !src.includes("transparent");
4413
+ if (isLikelyContent && ($(el).attr("width") || "0") !== "1") {
4414
+ issues.push({
4415
+ severity: "info",
4416
+ rule: "img-empty-alt",
4417
+ message: "Image has empty alt text \u2014 verify it is decorative",
4418
+ element: describeElement($, el),
4419
+ details: "Empty alt is correct for decorative images, but content images need descriptive alt text."
4420
+ });
4421
+ }
4422
+ } else if (/\.(png|jpg|jpeg|gif|svg|webp|bmp)$/i.test(alt)) {
4423
+ issues.push({
4424
+ severity: "error",
4425
+ rule: "img-filename-alt",
4426
+ message: "Image alt text is a filename, not a description",
4427
+ element: describeElement($, el),
4428
+ details: `Alt "${alt}" should describe the image content, not the file name.`
4429
+ });
4430
+ }
4431
+ });
4432
+ return issues;
4433
+ }
4434
+ function checkLinkAccessibility($) {
4435
+ const issues = [];
4436
+ $("a").each((_, el) => {
4437
+ const text = $(el).text().trim().toLowerCase();
4438
+ const ariaLabel = $(el).attr("aria-label");
4439
+ const title = $(el).attr("title");
4440
+ const imgAlt = $(el).find("img").attr("alt");
4441
+ if (!text && !ariaLabel && !title && !imgAlt) {
4442
+ issues.push({
4443
+ severity: "error",
4444
+ rule: "link-no-accessible-name",
4445
+ message: "Link has no accessible name",
4446
+ element: describeElement($, el),
4447
+ details: "Links need visible text, aria-label, or an image with alt text."
4448
+ });
4449
+ return;
4450
+ }
4451
+ if (text && GENERIC_LINK_TEXT2.has(text) && !ariaLabel) {
4452
+ issues.push({
4453
+ severity: "warning",
4454
+ rule: "link-generic-text",
4455
+ message: `Link text "${$(el).text().trim()}" is not descriptive`,
4456
+ element: describeElement($, el),
4457
+ details: "Screen readers often list links out of context. Use text that describes the destination."
4458
+ });
4459
+ }
4460
+ });
4461
+ return issues;
4462
+ }
4463
+ function checkTableAccessibility($) {
4464
+ const issues = [];
4465
+ $("table").each((_, el) => {
4466
+ const role = $(el).attr("role");
4467
+ const hasHeaders = $(el).find("th").length > 0;
4468
+ const looksLikeLayout = !hasHeaders;
4469
+ if (looksLikeLayout && role !== "presentation" && role !== "none") {
4470
+ const nestedTables = $(el).find("table").length;
4471
+ if (nestedTables > 0 || $(el).find("td").length > 2) {
4472
+ issues.push({
4473
+ severity: "info",
4474
+ rule: "table-missing-role",
4475
+ message: 'Layout table missing role="presentation"',
4476
+ element: `<table> with ${$(el).find("td").length} cells`,
4477
+ details: `Add role="presentation" to tables used for layout so screen readers don't announce them as data tables.`
4478
+ });
4479
+ }
4480
+ }
4481
+ });
4482
+ return issues;
4483
+ }
4484
+ function checkColorContrast($) {
4485
+ const issues = [];
4486
+ let smallTextCount = 0;
4487
+ $("[style]").each((_, el) => {
4488
+ const style = $(el).attr("style") || "";
4489
+ const fontSizeMatch = style.match(/font-size\s*:\s*(\d+(?:\.\d+)?)(px|pt)/i);
4490
+ if (fontSizeMatch) {
4491
+ const size = parseFloat(fontSizeMatch[1]);
4492
+ const unit = fontSizeMatch[2].toLowerCase();
4493
+ const pxSize = unit === "pt" ? size * 1.333 : size;
4494
+ if (pxSize < 10 && pxSize > 0) {
4495
+ smallTextCount++;
4496
+ if (smallTextCount <= 3) {
4497
+ issues.push({
4498
+ severity: "warning",
4499
+ rule: "small-text",
4500
+ message: `Very small text (${fontSizeMatch[0].trim()})`,
4501
+ element: describeElement($, el),
4502
+ details: "Text smaller than 10px is difficult to read, especially on mobile devices."
4503
+ });
4504
+ }
4505
+ }
4506
+ }
4507
+ });
4508
+ if (smallTextCount > 3) {
4509
+ issues.push({
4510
+ severity: "warning",
4511
+ rule: "small-text-multiple",
4512
+ message: `${smallTextCount} elements with text smaller than 10px`,
4513
+ details: "Consider using a minimum font size of 12-14px for readability."
4514
+ });
4515
+ }
4516
+ return issues;
4517
+ }
4518
+ function checkSemanticStructure($) {
4519
+ const issues = [];
4520
+ const headings = [];
4521
+ $("h1, h2, h3, h4, h5, h6").each((_, el) => {
4522
+ const level = parseInt(el.tagName.replace(/h/i, ""), 10);
4523
+ headings.push({ level, text: $(el).text().trim().slice(0, 60) });
4524
+ });
4525
+ for (let i = 1; i < headings.length; i++) {
4526
+ const gap = headings[i].level - headings[i - 1].level;
4527
+ if (gap > 1) {
4528
+ issues.push({
4529
+ severity: "info",
4530
+ rule: "heading-skip",
4531
+ message: `Heading level skipped: h${headings[i - 1].level} to h${headings[i].level}`,
4532
+ details: "Skipped heading levels can confuse screen readers. Use sequential heading levels."
4533
+ });
4534
+ break;
4535
+ }
4536
+ }
4537
+ return issues;
4538
+ }
4539
+ function checkAccessibility(html) {
4540
+ if (!html || !html.trim()) {
4541
+ return { score: 100, issues: [] };
4542
+ }
4543
+ const $ = cheerio6.load(html);
4544
+ const issues = [];
4545
+ const langIssue = checkLangAttribute($);
4546
+ if (langIssue) issues.push(langIssue);
4547
+ const titleIssue = checkTitle($);
4548
+ if (titleIssue) issues.push(titleIssue);
4549
+ issues.push(...checkImageAlt($));
4550
+ issues.push(...checkLinkAccessibility($));
4551
+ issues.push(...checkTableAccessibility($));
4552
+ issues.push(...checkColorContrast($));
4553
+ issues.push(...checkSemanticStructure($));
4554
+ let penalty = 0;
4555
+ for (const issue of issues) {
4556
+ switch (issue.severity) {
4557
+ case "error":
4558
+ penalty += 12;
4559
+ break;
4560
+ case "warning":
4561
+ penalty += 6;
4562
+ break;
4563
+ case "info":
4564
+ penalty += 2;
4565
+ break;
4566
+ }
4567
+ }
4568
+ const score = Math.max(0, 100 - penalty);
4569
+ return { score, issues };
4570
+ }
4571
+
4572
+ // src/image-analyzer.ts
4573
+ import * as cheerio7 from "cheerio";
4574
+ var DATA_URI_WARN_BYTES = 100 * 1024;
4575
+ var TOTAL_DATA_URI_WARN_BYTES = 500 * 1024;
4576
+ var HIGH_IMAGE_COUNT = 10;
4577
+ function estimateBase64Bytes(dataUri) {
4578
+ const commaIdx = dataUri.indexOf(",");
4579
+ if (commaIdx === -1) return 0;
4580
+ const payload = dataUri.slice(commaIdx + 1);
4581
+ return Math.floor(payload.length * 3 / 4);
4582
+ }
4583
+ function isTrackingPixel(el) {
4584
+ const width = el.attr("width");
4585
+ const height = el.attr("height");
4586
+ const style = (el.attr("style") || "").toLowerCase();
4587
+ if (width === "1" && height === "1") return true;
4588
+ if (width === "0" || height === "0") return true;
4589
+ if (style.includes("display:none") || style.includes("display: none") || style.includes("visibility:hidden") || style.includes("visibility: hidden")) {
4590
+ return true;
4591
+ }
4592
+ if (/width\s*:\s*1px/.test(style) && /height\s*:\s*1px/.test(style)) {
4593
+ return true;
4594
+ }
4595
+ return false;
4596
+ }
4597
+ function truncateSrc(src, max = 60) {
4598
+ if (src.startsWith("data:")) {
4599
+ const semi = src.indexOf(";");
4600
+ return semi > 0 ? src.slice(0, semi + 1) + "base64,..." : "data:...";
4601
+ }
4602
+ return src.length > max ? src.slice(0, max - 3) + "..." : src;
4603
+ }
4604
+ function analyzeImages(html) {
4605
+ if (!html || !html.trim()) {
4606
+ return { total: 0, totalDataUriBytes: 0, issues: [], images: [] };
4607
+ }
4608
+ const $ = cheerio7.load(html);
4609
+ const issues = [];
4610
+ const images = [];
4611
+ let totalDataUriBytes = 0;
4612
+ $("img").each((_, el) => {
4613
+ var _a, _b, _c;
4614
+ const img = $(el);
4615
+ const src = img.attr("src") || "";
4616
+ const alt = (_a = img.attr("alt")) != null ? _a : null;
4617
+ const width = (_b = img.attr("width")) != null ? _b : null;
4618
+ const height = (_c = img.attr("height")) != null ? _c : null;
4619
+ const style = (img.attr("style") || "").toLowerCase();
4620
+ const imgIssues = [];
4621
+ const tracking = isTrackingPixel(img);
4622
+ let dataUriBytes = 0;
4623
+ if (src.startsWith("data:")) {
4624
+ dataUriBytes = estimateBase64Bytes(src);
4625
+ totalDataUriBytes += dataUriBytes;
4626
+ }
4627
+ if (tracking) {
4628
+ images.push({
4629
+ src: truncateSrc(src),
4630
+ alt,
4631
+ width,
4632
+ height,
4633
+ isTrackingPixel: true,
4634
+ dataUriBytes,
4635
+ issues: ["tracking-pixel"]
4636
+ });
4637
+ return;
4638
+ }
4639
+ if (!width && !height) {
4640
+ const hasStyleWidth = /width\s*:/.test(style);
4641
+ const hasStyleHeight = /height\s*:/.test(style);
4642
+ if (!hasStyleWidth && !hasStyleHeight) {
4643
+ imgIssues.push("missing-dimensions");
4644
+ issues.push({
4645
+ rule: "missing-dimensions",
4646
+ severity: "warning",
4647
+ message: "Image missing width/height attributes \u2014 causes layout shifts and Outlook rendering issues.",
4648
+ src: truncateSrc(src)
4649
+ });
4650
+ }
4651
+ }
4652
+ if (dataUriBytes > DATA_URI_WARN_BYTES) {
4653
+ const kb = Math.round(dataUriBytes / 1024);
4654
+ imgIssues.push("large-data-uri");
4655
+ issues.push({
4656
+ rule: "large-data-uri",
4657
+ severity: "warning",
4658
+ message: `Data URI is ${kb}KB \u2014 consider hosting the image externally to reduce email size.`,
4659
+ src: truncateSrc(src)
4660
+ });
4661
+ }
4662
+ if (alt === null) {
4663
+ imgIssues.push("missing-alt");
4664
+ issues.push({
4665
+ rule: "missing-alt",
4666
+ severity: "warning",
4667
+ message: "Image missing alt attribute \u2014 hurts deliverability and accessibility.",
4668
+ src: truncateSrc(src)
4669
+ });
4670
+ }
4671
+ if (src.toLowerCase().endsWith(".webp") || src.includes("image/webp")) {
4672
+ imgIssues.push("webp-format");
4673
+ issues.push({
4674
+ rule: "webp-format",
4675
+ severity: "info",
4676
+ message: "WebP format detected \u2014 not supported by all email clients. Consider PNG or JPEG.",
4677
+ src: truncateSrc(src)
4678
+ });
4679
+ }
4680
+ if (src.toLowerCase().endsWith(".svg") || src.includes("image/svg")) {
4681
+ imgIssues.push("svg-format");
4682
+ issues.push({
4683
+ rule: "svg-format",
4684
+ severity: "info",
4685
+ message: "SVG format detected \u2014 not supported by most email clients. Use PNG instead.",
4686
+ src: truncateSrc(src)
4687
+ });
4688
+ }
4689
+ if (!style.includes("display:block") && !style.includes("display: block")) {
4690
+ imgIssues.push("missing-display-block");
4691
+ issues.push({
4692
+ rule: "missing-display-block",
4693
+ severity: "info",
4694
+ message: "Image without display:block \u2014 may cause unwanted gaps in Outlook.",
4695
+ src: truncateSrc(src)
4696
+ });
4697
+ }
4698
+ images.push({
4699
+ src: truncateSrc(src),
4700
+ alt,
4701
+ width,
4702
+ height,
4703
+ isTrackingPixel: false,
4704
+ dataUriBytes,
4705
+ issues: imgIssues
4706
+ });
4707
+ });
4708
+ const nonTrackingImages = images.filter((i) => !i.isTrackingPixel);
4709
+ if (nonTrackingImages.length > HIGH_IMAGE_COUNT) {
4710
+ issues.push({
4711
+ rule: "high-image-count",
4712
+ severity: "info",
4713
+ message: `Email contains ${nonTrackingImages.length} images \u2014 heavy emails may be clipped or load slowly.`
4714
+ });
4715
+ }
4716
+ const trackingPixels = images.filter((i) => i.isTrackingPixel);
4717
+ if (trackingPixels.length > 0) {
4718
+ issues.push({
4719
+ rule: "tracking-pixel",
4720
+ severity: "info",
4721
+ message: `${trackingPixels.length} tracking pixel${trackingPixels.length > 1 ? "s" : ""} detected.`
4722
+ });
4723
+ }
4724
+ if (totalDataUriBytes > TOTAL_DATA_URI_WARN_BYTES) {
4725
+ const kb = Math.round(totalDataUriBytes / 1024);
4726
+ issues.push({
4727
+ rule: "total-data-uri-size",
4728
+ severity: "warning",
4729
+ message: `Total data URI size is ${kb}KB \u2014 consider hosting images externally to reduce email size.`
4730
+ });
4731
+ }
4732
+ return { total: images.length, totalDataUriBytes, issues, images };
4733
+ }
3912
4734
  export {
3913
4735
  AI_FIX_SYSTEM_PROMPT,
3914
4736
  EMAIL_CLIENTS,
3915
4737
  STRUCTURAL_FIX_PROPERTIES,
3916
4738
  analyzeEmail,
4739
+ analyzeImages,
4740
+ analyzeSpam,
4741
+ checkAccessibility,
3917
4742
  diffResults,
3918
4743
  estimateAiFixTokens,
3919
4744
  generateAiFix,
@@ -3925,6 +4750,7 @@ export {
3925
4750
  heuristicTokenCount,
3926
4751
  simulateDarkMode,
3927
4752
  transformForAllClients,
3928
- transformForClient
4753
+ transformForClient,
4754
+ validateLinks
3929
4755
  };
3930
4756
  //# sourceMappingURL=index.js.map