@emailens/engine 0.1.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/README.md +122 -2
- package/dist/index.d.ts +273 -15
- package/dist/index.js +1415 -18
- package/dist/index.js.map +1 -1
- package/package.json +7 -3
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
var __defProp = Object.defineProperty;
|
|
2
|
+
var __defProps = Object.defineProperties;
|
|
3
|
+
var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
|
|
2
4
|
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
|
|
3
5
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
4
6
|
var __propIsEnum = Object.prototype.propertyIsEnumerable;
|
|
@@ -14,6 +16,19 @@ var __spreadValues = (a, b) => {
|
|
|
14
16
|
}
|
|
15
17
|
return a;
|
|
16
18
|
};
|
|
19
|
+
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
|
|
20
|
+
var __objRest = (source, exclude) => {
|
|
21
|
+
var target = {};
|
|
22
|
+
for (var prop in source)
|
|
23
|
+
if (__hasOwnProp.call(source, prop) && exclude.indexOf(prop) < 0)
|
|
24
|
+
target[prop] = source[prop];
|
|
25
|
+
if (source != null && __getOwnPropSymbols)
|
|
26
|
+
for (var prop of __getOwnPropSymbols(source)) {
|
|
27
|
+
if (exclude.indexOf(prop) < 0 && __propIsEnum.call(source, prop))
|
|
28
|
+
target[prop] = source[prop];
|
|
29
|
+
}
|
|
30
|
+
return target;
|
|
31
|
+
};
|
|
17
32
|
|
|
18
33
|
// src/clients.ts
|
|
19
34
|
var EMAIL_CLIENTS = [
|
|
@@ -730,6 +745,189 @@ var CSS_SUPPORT = {
|
|
|
730
745
|
"superhuman": "unsupported"
|
|
731
746
|
// Superhuman strips forms for security
|
|
732
747
|
},
|
|
748
|
+
// --- Text Wrapping ---
|
|
749
|
+
"word-break": {
|
|
750
|
+
"gmail-web": "supported",
|
|
751
|
+
"gmail-android": "supported",
|
|
752
|
+
"gmail-ios": "supported",
|
|
753
|
+
"outlook-web": "supported",
|
|
754
|
+
"outlook-windows": "unsupported",
|
|
755
|
+
// Word engine ignores word-break entirely
|
|
756
|
+
"apple-mail-macos": "supported",
|
|
757
|
+
"apple-mail-ios": "supported",
|
|
758
|
+
"yahoo-mail": "partial",
|
|
759
|
+
// break-all partially works; break-word unreliable
|
|
760
|
+
"samsung-mail": "supported",
|
|
761
|
+
"thunderbird": "supported",
|
|
762
|
+
"hey-mail": "supported",
|
|
763
|
+
"superhuman": "supported"
|
|
764
|
+
},
|
|
765
|
+
"overflow-wrap": {
|
|
766
|
+
"gmail-web": "supported",
|
|
767
|
+
"gmail-android": "supported",
|
|
768
|
+
"gmail-ios": "supported",
|
|
769
|
+
"outlook-web": "supported",
|
|
770
|
+
"outlook-windows": "unsupported",
|
|
771
|
+
// Word engine ignores overflow-wrap
|
|
772
|
+
"apple-mail-macos": "supported",
|
|
773
|
+
"apple-mail-ios": "supported",
|
|
774
|
+
"yahoo-mail": "partial",
|
|
775
|
+
// inconsistent support
|
|
776
|
+
"samsung-mail": "supported",
|
|
777
|
+
"thunderbird": "supported",
|
|
778
|
+
"hey-mail": "supported",
|
|
779
|
+
"superhuman": "supported"
|
|
780
|
+
},
|
|
781
|
+
"white-space": {
|
|
782
|
+
"gmail-web": "supported",
|
|
783
|
+
"gmail-android": "supported",
|
|
784
|
+
"gmail-ios": "supported",
|
|
785
|
+
"outlook-web": "supported",
|
|
786
|
+
"outlook-windows": "partial",
|
|
787
|
+
// only normal and nowrap
|
|
788
|
+
"apple-mail-macos": "supported",
|
|
789
|
+
"apple-mail-ios": "supported",
|
|
790
|
+
"yahoo-mail": "supported",
|
|
791
|
+
"samsung-mail": "supported",
|
|
792
|
+
"thunderbird": "supported",
|
|
793
|
+
"hey-mail": "supported",
|
|
794
|
+
"superhuman": "supported"
|
|
795
|
+
},
|
|
796
|
+
"text-overflow": {
|
|
797
|
+
"gmail-web": "unsupported",
|
|
798
|
+
// Gmail strips overflow, so text-overflow is useless
|
|
799
|
+
"gmail-android": "unsupported",
|
|
800
|
+
"gmail-ios": "unsupported",
|
|
801
|
+
"outlook-web": "supported",
|
|
802
|
+
"outlook-windows": "unsupported",
|
|
803
|
+
"apple-mail-macos": "supported",
|
|
804
|
+
"apple-mail-ios": "supported",
|
|
805
|
+
"yahoo-mail": "partial",
|
|
806
|
+
"samsung-mail": "supported",
|
|
807
|
+
"thunderbird": "supported",
|
|
808
|
+
"hey-mail": "supported",
|
|
809
|
+
"superhuman": "supported"
|
|
810
|
+
},
|
|
811
|
+
// --- Table Layout ---
|
|
812
|
+
"vertical-align": {
|
|
813
|
+
"gmail-web": "supported",
|
|
814
|
+
"gmail-android": "supported",
|
|
815
|
+
"gmail-ios": "supported",
|
|
816
|
+
"outlook-web": "supported",
|
|
817
|
+
"outlook-windows": "partial",
|
|
818
|
+
// only on <td> elements via valign attribute
|
|
819
|
+
"apple-mail-macos": "supported",
|
|
820
|
+
"apple-mail-ios": "supported",
|
|
821
|
+
"yahoo-mail": "supported",
|
|
822
|
+
"samsung-mail": "supported",
|
|
823
|
+
"thunderbird": "supported",
|
|
824
|
+
"hey-mail": "supported",
|
|
825
|
+
"superhuman": "supported"
|
|
826
|
+
},
|
|
827
|
+
"border-spacing": {
|
|
828
|
+
"gmail-web": "supported",
|
|
829
|
+
"gmail-android": "supported",
|
|
830
|
+
"gmail-ios": "supported",
|
|
831
|
+
"outlook-web": "supported",
|
|
832
|
+
"outlook-windows": "unsupported",
|
|
833
|
+
// use cellspacing attribute instead
|
|
834
|
+
"apple-mail-macos": "supported",
|
|
835
|
+
"apple-mail-ios": "supported",
|
|
836
|
+
"yahoo-mail": "supported",
|
|
837
|
+
"samsung-mail": "supported",
|
|
838
|
+
"thunderbird": "supported",
|
|
839
|
+
"hey-mail": "supported",
|
|
840
|
+
"superhuman": "supported"
|
|
841
|
+
},
|
|
842
|
+
// --- Sizing ---
|
|
843
|
+
"min-width": {
|
|
844
|
+
"gmail-web": "supported",
|
|
845
|
+
"gmail-android": "supported",
|
|
846
|
+
"gmail-ios": "supported",
|
|
847
|
+
"outlook-web": "supported",
|
|
848
|
+
"outlook-windows": "unsupported",
|
|
849
|
+
// Word engine ignores min-width
|
|
850
|
+
"apple-mail-macos": "supported",
|
|
851
|
+
"apple-mail-ios": "supported",
|
|
852
|
+
"yahoo-mail": "supported",
|
|
853
|
+
"samsung-mail": "supported",
|
|
854
|
+
"thunderbird": "supported",
|
|
855
|
+
"hey-mail": "supported",
|
|
856
|
+
"superhuman": "supported"
|
|
857
|
+
},
|
|
858
|
+
"min-height": {
|
|
859
|
+
"gmail-web": "supported",
|
|
860
|
+
"gmail-android": "supported",
|
|
861
|
+
"gmail-ios": "supported",
|
|
862
|
+
"outlook-web": "supported",
|
|
863
|
+
"outlook-windows": "unsupported",
|
|
864
|
+
// Word engine ignores min-height
|
|
865
|
+
"apple-mail-macos": "supported",
|
|
866
|
+
"apple-mail-ios": "supported",
|
|
867
|
+
"yahoo-mail": "supported",
|
|
868
|
+
"samsung-mail": "supported",
|
|
869
|
+
"thunderbird": "supported",
|
|
870
|
+
"hey-mail": "supported",
|
|
871
|
+
"superhuman": "supported"
|
|
872
|
+
},
|
|
873
|
+
"max-height": {
|
|
874
|
+
"gmail-web": "supported",
|
|
875
|
+
"gmail-android": "supported",
|
|
876
|
+
"gmail-ios": "supported",
|
|
877
|
+
"outlook-web": "supported",
|
|
878
|
+
"outlook-windows": "unsupported",
|
|
879
|
+
"apple-mail-macos": "supported",
|
|
880
|
+
"apple-mail-ios": "supported",
|
|
881
|
+
"yahoo-mail": "supported",
|
|
882
|
+
"samsung-mail": "supported",
|
|
883
|
+
"thunderbird": "supported",
|
|
884
|
+
"hey-mail": "supported",
|
|
885
|
+
"superhuman": "supported"
|
|
886
|
+
},
|
|
887
|
+
// --- Shadows ---
|
|
888
|
+
"text-shadow": {
|
|
889
|
+
"gmail-web": "unsupported",
|
|
890
|
+
"gmail-android": "unsupported",
|
|
891
|
+
"gmail-ios": "unsupported",
|
|
892
|
+
"outlook-web": "supported",
|
|
893
|
+
"outlook-windows": "unsupported",
|
|
894
|
+
"apple-mail-macos": "supported",
|
|
895
|
+
"apple-mail-ios": "supported",
|
|
896
|
+
"yahoo-mail": "unsupported",
|
|
897
|
+
"samsung-mail": "supported",
|
|
898
|
+
"thunderbird": "supported",
|
|
899
|
+
"hey-mail": "supported",
|
|
900
|
+
"superhuman": "supported"
|
|
901
|
+
},
|
|
902
|
+
// --- Background Sub-properties ---
|
|
903
|
+
"background-size": {
|
|
904
|
+
"gmail-web": "supported",
|
|
905
|
+
"gmail-android": "supported",
|
|
906
|
+
"gmail-ios": "supported",
|
|
907
|
+
"outlook-web": "supported",
|
|
908
|
+
"outlook-windows": "unsupported",
|
|
909
|
+
"apple-mail-macos": "supported",
|
|
910
|
+
"apple-mail-ios": "supported",
|
|
911
|
+
"yahoo-mail": "partial",
|
|
912
|
+
"samsung-mail": "supported",
|
|
913
|
+
"thunderbird": "supported",
|
|
914
|
+
"hey-mail": "supported",
|
|
915
|
+
"superhuman": "supported"
|
|
916
|
+
},
|
|
917
|
+
"background-position": {
|
|
918
|
+
"gmail-web": "supported",
|
|
919
|
+
"gmail-android": "supported",
|
|
920
|
+
"gmail-ios": "supported",
|
|
921
|
+
"outlook-web": "supported",
|
|
922
|
+
"outlook-windows": "unsupported",
|
|
923
|
+
"apple-mail-macos": "supported",
|
|
924
|
+
"apple-mail-ios": "supported",
|
|
925
|
+
"yahoo-mail": "partial",
|
|
926
|
+
"samsung-mail": "supported",
|
|
927
|
+
"thunderbird": "supported",
|
|
928
|
+
"hey-mail": "supported",
|
|
929
|
+
"superhuman": "supported"
|
|
930
|
+
},
|
|
733
931
|
// --- Misc ---
|
|
734
932
|
"opacity": {
|
|
735
933
|
"gmail-web": "unsupported",
|
|
@@ -822,6 +1020,8 @@ var OUTLOOK_WORD_UNSUPPORTED = /* @__PURE__ */ new Set([
|
|
|
822
1020
|
"text-shadow",
|
|
823
1021
|
"max-width",
|
|
824
1022
|
"max-height",
|
|
1023
|
+
"min-width",
|
|
1024
|
+
"min-height",
|
|
825
1025
|
"float",
|
|
826
1026
|
"position",
|
|
827
1027
|
"display",
|
|
@@ -834,7 +1034,30 @@ var OUTLOOK_WORD_UNSUPPORTED = /* @__PURE__ */ new Set([
|
|
|
834
1034
|
"background-position",
|
|
835
1035
|
"box-sizing",
|
|
836
1036
|
"object-fit",
|
|
837
|
-
"gap"
|
|
1037
|
+
"gap",
|
|
1038
|
+
"word-break",
|
|
1039
|
+
"overflow-wrap",
|
|
1040
|
+
"text-overflow",
|
|
1041
|
+
"border-spacing"
|
|
1042
|
+
]);
|
|
1043
|
+
var STRUCTURAL_FIX_PROPERTIES = /* @__PURE__ */ new Set([
|
|
1044
|
+
"display:flex",
|
|
1045
|
+
"display:grid",
|
|
1046
|
+
"word-break",
|
|
1047
|
+
"overflow-wrap",
|
|
1048
|
+
"text-overflow",
|
|
1049
|
+
"position",
|
|
1050
|
+
"float",
|
|
1051
|
+
"gap",
|
|
1052
|
+
"max-width",
|
|
1053
|
+
"border-radius",
|
|
1054
|
+
"background-image",
|
|
1055
|
+
"background-size",
|
|
1056
|
+
"background-position",
|
|
1057
|
+
"<svg>",
|
|
1058
|
+
"<video>",
|
|
1059
|
+
"<form>",
|
|
1060
|
+
"object-fit"
|
|
838
1061
|
]);
|
|
839
1062
|
|
|
840
1063
|
// src/fix-snippets.ts
|
|
@@ -1340,6 +1563,146 @@ h1 {
|
|
|
1340
1563
|
<div style="font-size: 0; max-height: 0; overflow: hidden;
|
|
1341
1564
|
mso-hide: all;" aria-hidden="true">
|
|
1342
1565
|
Preheader text
|
|
1566
|
+
</div>`
|
|
1567
|
+
},
|
|
1568
|
+
// ── word-break → table cell wrapping ─────────────────────────────────────
|
|
1569
|
+
"word-break": {
|
|
1570
|
+
language: "html",
|
|
1571
|
+
description: "Wrap long text in a table cell to force line breaks without word-break",
|
|
1572
|
+
before: `<span style="word-break: break-all;">
|
|
1573
|
+
https://example.com/very/long/url?token=abc123def456
|
|
1574
|
+
</span>`,
|
|
1575
|
+
after: `<!-- Table cells force text wrapping in all clients including Outlook -->
|
|
1576
|
+
<table role="presentation" width="100%" cellpadding="0"
|
|
1577
|
+
cellspacing="0" border="0">
|
|
1578
|
+
<tr>
|
|
1579
|
+
<td style="word-break: break-all; overflow-wrap: break-word;
|
|
1580
|
+
word-wrap: break-word;">
|
|
1581
|
+
https://example.com/very/long/url?token=abc123def456
|
|
1582
|
+
</td>
|
|
1583
|
+
</tr>
|
|
1584
|
+
</table>`
|
|
1585
|
+
},
|
|
1586
|
+
"word-break::jsx": {
|
|
1587
|
+
language: "jsx",
|
|
1588
|
+
description: "Wrap long text in a table cell for Outlook-safe word breaking",
|
|
1589
|
+
before: `<span style={{ wordBreak: "break-all" }}>{url}</span>`,
|
|
1590
|
+
after: `{/* Table cells force text wrapping in Outlook and Yahoo */}
|
|
1591
|
+
<table width="100%" cellPadding={0} cellSpacing={0}
|
|
1592
|
+
role="presentation" style={{ borderCollapse: "collapse" }}>
|
|
1593
|
+
<tr>
|
|
1594
|
+
<td style={{
|
|
1595
|
+
wordBreak: "break-all" as const,
|
|
1596
|
+
overflowWrap: "break-word" as const,
|
|
1597
|
+
wordWrap: "break-word" as const,
|
|
1598
|
+
}}>
|
|
1599
|
+
{url}
|
|
1600
|
+
</td>
|
|
1601
|
+
</tr>
|
|
1602
|
+
</table>`
|
|
1603
|
+
},
|
|
1604
|
+
"word-break::mjml": {
|
|
1605
|
+
language: "mjml",
|
|
1606
|
+
description: "MJML renders text in table cells by default \u2014 word-break works via mj-text",
|
|
1607
|
+
before: `<mj-text>
|
|
1608
|
+
<span style="word-break: break-all;">Long URL here</span>
|
|
1609
|
+
</mj-text>`,
|
|
1610
|
+
after: `<!-- mj-text already renders inside a <td>, so add word-break
|
|
1611
|
+
to the mj-text css-class or inline style -->
|
|
1612
|
+
<mj-text css-class="break-words"
|
|
1613
|
+
padding="0">
|
|
1614
|
+
Long URL here
|
|
1615
|
+
</mj-text>
|
|
1616
|
+
<mj-style>
|
|
1617
|
+
.break-words td { word-break: break-all; word-wrap: break-word; }
|
|
1618
|
+
</mj-style>`
|
|
1619
|
+
},
|
|
1620
|
+
// ── overflow-wrap → table cell wrapping ────────────────────────────────
|
|
1621
|
+
"overflow-wrap": {
|
|
1622
|
+
language: "html",
|
|
1623
|
+
description: "Use a table cell to force word wrapping without overflow-wrap",
|
|
1624
|
+
before: `<p style="overflow-wrap: break-word;">
|
|
1625
|
+
https://example.com/very/long/url?token=abc123def456
|
|
1626
|
+
</p>`,
|
|
1627
|
+
after: `<table role="presentation" width="100%" cellpadding="0"
|
|
1628
|
+
cellspacing="0" border="0">
|
|
1629
|
+
<tr>
|
|
1630
|
+
<td style="overflow-wrap: break-word; word-wrap: break-word;
|
|
1631
|
+
word-break: break-all;">
|
|
1632
|
+
https://example.com/very/long/url?token=abc123def456
|
|
1633
|
+
</td>
|
|
1634
|
+
</tr>
|
|
1635
|
+
</table>`
|
|
1636
|
+
},
|
|
1637
|
+
"overflow-wrap::jsx": {
|
|
1638
|
+
language: "jsx",
|
|
1639
|
+
description: "Wrap text in a table cell for Outlook-safe overflow wrapping",
|
|
1640
|
+
before: `<p style={{ overflowWrap: "break-word" }}>{longText}</p>`,
|
|
1641
|
+
after: `<table width="100%" cellPadding={0} cellSpacing={0}
|
|
1642
|
+
role="presentation" style={{ borderCollapse: "collapse" }}>
|
|
1643
|
+
<tr>
|
|
1644
|
+
<td style={{
|
|
1645
|
+
overflowWrap: "break-word" as const,
|
|
1646
|
+
wordWrap: "break-word" as const,
|
|
1647
|
+
wordBreak: "break-all" as const,
|
|
1648
|
+
}}>
|
|
1649
|
+
{longText}
|
|
1650
|
+
</td>
|
|
1651
|
+
</tr>
|
|
1652
|
+
</table>`
|
|
1653
|
+
},
|
|
1654
|
+
// ── text-shadow → border/font-weight alternative ───────────────────────
|
|
1655
|
+
"text-shadow": {
|
|
1656
|
+
language: "css",
|
|
1657
|
+
description: "Use font-weight or border-bottom as alternatives to text-shadow",
|
|
1658
|
+
before: `.glow {
|
|
1659
|
+
text-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
|
|
1660
|
+
}`,
|
|
1661
|
+
after: `.glow {
|
|
1662
|
+
/* text-shadow is not supported in Gmail, Outlook, Yahoo.
|
|
1663
|
+
Use font-weight or letter-spacing for emphasis instead. */
|
|
1664
|
+
font-weight: bold;
|
|
1665
|
+
letter-spacing: 0.5px;
|
|
1666
|
+
}`
|
|
1667
|
+
},
|
|
1668
|
+
// ── border-spacing → cellspacing attribute ─────────────────────────────
|
|
1669
|
+
"border-spacing": {
|
|
1670
|
+
language: "html",
|
|
1671
|
+
description: "Use the cellspacing HTML attribute instead of border-spacing CSS",
|
|
1672
|
+
before: `<table style="border-spacing: 8px; border-collapse: separate;">
|
|
1673
|
+
<tr><td>Cell</td></tr>
|
|
1674
|
+
</table>`,
|
|
1675
|
+
after: `<table cellspacing="8" style="border-collapse: separate;">
|
|
1676
|
+
<tr><td>Cell</td></tr>
|
|
1677
|
+
</table>`
|
|
1678
|
+
},
|
|
1679
|
+
// ── min-width → fixed width ────────────────────────────────────────────
|
|
1680
|
+
"min-width": {
|
|
1681
|
+
language: "html",
|
|
1682
|
+
description: "Use a fixed width instead of min-width for Outlook compatibility",
|
|
1683
|
+
before: `<td style="min-width: 200px;">Content</td>`,
|
|
1684
|
+
after: `<!-- Outlook ignores min-width. Use a fixed width or a spacer. -->
|
|
1685
|
+
<td width="200" style="width: 200px;">Content</td>`
|
|
1686
|
+
},
|
|
1687
|
+
// ── min-height → fixed height ──────────────────────────────────────────
|
|
1688
|
+
"min-height": {
|
|
1689
|
+
language: "html",
|
|
1690
|
+
description: "Use a fixed height or spacer instead of min-height",
|
|
1691
|
+
before: `<td style="min-height: 100px;">Content</td>`,
|
|
1692
|
+
after: `<!-- Outlook ignores min-height. Use height or a spacer image. -->
|
|
1693
|
+
<td height="100" style="height: 100px;">Content</td>`
|
|
1694
|
+
},
|
|
1695
|
+
// ── max-height → fixed height ──────────────────────────────────────────
|
|
1696
|
+
"max-height": {
|
|
1697
|
+
language: "html",
|
|
1698
|
+
description: "Outlook ignores max-height \u2014 truncate content server-side",
|
|
1699
|
+
before: `<div style="max-height: 200px; overflow: hidden;">
|
|
1700
|
+
Long content...
|
|
1701
|
+
</div>`,
|
|
1702
|
+
after: `<!-- Outlook ignores max-height. Truncate content server-side. -->
|
|
1703
|
+
<div style="height: 200px;">
|
|
1704
|
+
Shortened content...
|
|
1705
|
+
<a href="https://example.com/full">Read more</a>
|
|
1343
1706
|
</div>`
|
|
1344
1707
|
},
|
|
1345
1708
|
// ── REACT EMAIL (jsx) framework-specific fixes ────────────────────────────
|
|
@@ -2074,6 +2437,8 @@ function getClientPrefix(clientId) {
|
|
|
2074
2437
|
if (clientId.startsWith("outlook")) return null;
|
|
2075
2438
|
if (clientId.startsWith("gmail")) return "gmail";
|
|
2076
2439
|
if (clientId.startsWith("apple-mail")) return "apple";
|
|
2440
|
+
if (clientId === "yahoo-mail") return "yahoo";
|
|
2441
|
+
if (clientId === "samsung-mail") return "samsung";
|
|
2077
2442
|
return null;
|
|
2078
2443
|
}
|
|
2079
2444
|
var SUGGESTION_DATABASE = {
|
|
@@ -2177,6 +2542,31 @@ var SUGGESTION_DATABASE = {
|
|
|
2177
2542
|
"opacity::jsx": "Use solid colors. Opacity is not supported in many email clients.",
|
|
2178
2543
|
"opacity::mjml": "Use solid colors. Most email clients don't support opacity.",
|
|
2179
2544
|
"opacity::maizzle": "Use solid Tailwind color classes instead of opacity.",
|
|
2545
|
+
// ── word-break ──────────────────────────────────────────────────────
|
|
2546
|
+
"word-break": "Wrap long text in a <table><td> to force wrapping in clients that don't support word-break.",
|
|
2547
|
+
"word-break::outlook": "Outlook's Word engine ignores word-break. Place text inside a <td> with a constrained width \u2014 tables always wrap.",
|
|
2548
|
+
"word-break::jsx": "Wrap long text in a <table><tr><td> element. Outlook ignores wordBreak but respects table cell widths.",
|
|
2549
|
+
"word-break::mjml": "mj-text renders inside a <td>, which helps. Add word-wrap: break-word to the td via mj-style.",
|
|
2550
|
+
// ── overflow-wrap ──────────────────────────────────────────────────
|
|
2551
|
+
"overflow-wrap": "Wrap text in a <table><td> to force wrapping. overflow-wrap is ignored by Outlook and unreliable in Yahoo.",
|
|
2552
|
+
"overflow-wrap::jsx": "Wrap text in a <table><tr><td> element. Outlook ignores overflowWrap but respects table cell widths.",
|
|
2553
|
+
// ── white-space ────────────────────────────────────────────────────
|
|
2554
|
+
"white-space": "Outlook only supports 'normal' and 'nowrap'. Use for non-breaking spaces.",
|
|
2555
|
+
// ── text-overflow ──────────────────────────────────────────────────
|
|
2556
|
+
"text-overflow": "text-overflow requires overflow:hidden which is stripped by Gmail. Truncate content server-side.",
|
|
2557
|
+
// ── vertical-align ─────────────────────────────────────────────────
|
|
2558
|
+
"vertical-align": 'Use the valign HTML attribute on <td> elements for Outlook (e.g., valign="top").',
|
|
2559
|
+
// ── border-spacing ─────────────────────────────────────────────────
|
|
2560
|
+
"border-spacing": 'Use the cellspacing HTML attribute instead (e.g., <table cellspacing="8">).',
|
|
2561
|
+
// ── min-width / min-height ─────────────────────────────────────────
|
|
2562
|
+
"min-width": "Outlook ignores min-width. Use a fixed width attribute on <td> or <table>.",
|
|
2563
|
+
"min-height": "Outlook ignores min-height. Use a fixed height or a spacer image.",
|
|
2564
|
+
"max-height": "Outlook ignores max-height. Truncate content server-side or use a fixed height.",
|
|
2565
|
+
// ── text-shadow ────────────────────────────────────────────────────
|
|
2566
|
+
"text-shadow": "text-shadow is stripped by Gmail, Outlook, and Yahoo. Use font-weight for emphasis.",
|
|
2567
|
+
// ── background-size / background-position ──────────────────────────
|
|
2568
|
+
"background-size": "Not supported in many clients. Set image dimensions directly.",
|
|
2569
|
+
"background-position": "Not supported in many clients. Use VML for positioning.",
|
|
2180
2570
|
// ── Additional properties covered by transform helpers ────────────────
|
|
2181
2571
|
"overflow": "Content will always be visible. Design accordingly.",
|
|
2182
2572
|
"visibility": "Remove the element or use display:none as an alternative.",
|
|
@@ -2185,9 +2575,6 @@ var SUGGESTION_DATABASE = {
|
|
|
2185
2575
|
"transition": "CSS transitions are not supported in email.",
|
|
2186
2576
|
"box-sizing": "Account for padding in your width calculations (use padding on a nested element).",
|
|
2187
2577
|
"object-fit": "Use width/height attributes on <img> directly.",
|
|
2188
|
-
"max-height": "Use fixed height instead.",
|
|
2189
|
-
"background-size": "Not supported in many clients. Set image dimensions directly.",
|
|
2190
|
-
"background-position": "Not supported in many clients. Use VML for positioning.",
|
|
2191
2578
|
"display": "Use tables for layout in email clients."
|
|
2192
2579
|
};
|
|
2193
2580
|
function getSuggestion(property, clientId, framework) {
|
|
@@ -2329,7 +2716,9 @@ function makeWarning(base, prop, clientId, framework) {
|
|
|
2329
2716
|
const sug = getSuggestion(prop, clientId, framework);
|
|
2330
2717
|
const fix = getCodeFix(prop, clientId, framework);
|
|
2331
2718
|
const isFallback = framework && ((sug == null ? void 0 : sug.isGenericFallback) || fix && isCodeFixGenericFallback(prop, clientId, framework));
|
|
2332
|
-
return __spreadValues(__spreadValues(__spreadValues(__spreadValues({}, base), sug ? { suggestion: sug.text } : {}), fix ? { fix } : {}), isFallback ? { fixIsGenericFallback: true } : {})
|
|
2719
|
+
return __spreadProps(__spreadValues(__spreadValues(__spreadValues(__spreadValues({}, base), sug ? { suggestion: sug.text } : {}), fix ? { fix } : {}), isFallback ? { fixIsGenericFallback: true } : {}), {
|
|
2720
|
+
fixType: STRUCTURAL_FIX_PROPERTIES.has(prop) ? "structural" : "css"
|
|
2721
|
+
});
|
|
2333
2722
|
}
|
|
2334
2723
|
function transformGmail(html, clientId, framework) {
|
|
2335
2724
|
const $ = cheerio.load(html);
|
|
@@ -2870,7 +3259,8 @@ function analyzeEmail(html, framework) {
|
|
|
2870
3259
|
property: "<style>",
|
|
2871
3260
|
message: `${client.name} strips <style> blocks. Styles must be inlined.`,
|
|
2872
3261
|
suggestion: sug.text,
|
|
2873
|
-
fix
|
|
3262
|
+
fix,
|
|
3263
|
+
fixType: getFixType("<style>")
|
|
2874
3264
|
}, framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback("<style>", client.id, framework)) ? { fixIsGenericFallback: true } : {}));
|
|
2875
3265
|
} else if (support === "partial") {
|
|
2876
3266
|
const sug = getSuggestion("<style>:partial", client.id, framework);
|
|
@@ -2881,7 +3271,8 @@ function analyzeEmail(html, framework) {
|
|
|
2881
3271
|
property: "<style>",
|
|
2882
3272
|
message: `${client.name} has partial <style> support (head only, with limitations). Inline styles recommended.`,
|
|
2883
3273
|
suggestion: sug.text,
|
|
2884
|
-
fix
|
|
3274
|
+
fix,
|
|
3275
|
+
fixType: getFixType("<style>")
|
|
2885
3276
|
}, framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback("<style>", client.id, framework)) ? { fixIsGenericFallback: true } : {}));
|
|
2886
3277
|
}
|
|
2887
3278
|
}
|
|
@@ -2898,7 +3289,8 @@ function analyzeEmail(html, framework) {
|
|
|
2898
3289
|
property: "<link>",
|
|
2899
3290
|
message: `${client.name} does not support external stylesheets.`,
|
|
2900
3291
|
suggestion: sug.text,
|
|
2901
|
-
fix
|
|
3292
|
+
fix,
|
|
3293
|
+
fixType: getFixType("<link>")
|
|
2902
3294
|
}, framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback("<link>", client.id, framework)) ? { fixIsGenericFallback: true } : {}));
|
|
2903
3295
|
}
|
|
2904
3296
|
}
|
|
@@ -2915,7 +3307,8 @@ function analyzeEmail(html, framework) {
|
|
|
2915
3307
|
property: "<svg>",
|
|
2916
3308
|
message: `${client.name} does not support inline SVG.`,
|
|
2917
3309
|
suggestion: sug.text,
|
|
2918
|
-
fix
|
|
3310
|
+
fix,
|
|
3311
|
+
fixType: getFixType("<svg>")
|
|
2919
3312
|
}, framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback("<svg>", client.id, framework)) ? { fixIsGenericFallback: true } : {}));
|
|
2920
3313
|
}
|
|
2921
3314
|
}
|
|
@@ -2932,7 +3325,8 @@ function analyzeEmail(html, framework) {
|
|
|
2932
3325
|
property: "<video>",
|
|
2933
3326
|
message: `${client.name} does not support <video> elements.`,
|
|
2934
3327
|
suggestion: sug.text,
|
|
2935
|
-
fix
|
|
3328
|
+
fix,
|
|
3329
|
+
fixType: getFixType("<video>")
|
|
2936
3330
|
}, framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback("<video>", client.id, framework)) ? { fixIsGenericFallback: true } : {}));
|
|
2937
3331
|
}
|
|
2938
3332
|
}
|
|
@@ -2949,7 +3343,8 @@ function analyzeEmail(html, framework) {
|
|
|
2949
3343
|
property: "<form>",
|
|
2950
3344
|
message: `${client.name} strips form elements.`,
|
|
2951
3345
|
suggestion: sug.text,
|
|
2952
|
-
fix
|
|
3346
|
+
fix,
|
|
3347
|
+
fixType: getFixType("<form>")
|
|
2953
3348
|
}, framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback("<form>", client.id, framework)) ? { fixIsGenericFallback: true } : {}));
|
|
2954
3349
|
}
|
|
2955
3350
|
}
|
|
@@ -2994,7 +3389,8 @@ function analyzeEmail(html, framework) {
|
|
|
2994
3389
|
property: "@font-face",
|
|
2995
3390
|
message: `${client.name} does not support web fonts (@font-face).`,
|
|
2996
3391
|
suggestion: sug.text,
|
|
2997
|
-
fix
|
|
3392
|
+
fix,
|
|
3393
|
+
fixType: getFixType("@font-face")
|
|
2998
3394
|
}, framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback("@font-face", client.id, framework)) ? { fixIsGenericFallback: true } : {}));
|
|
2999
3395
|
}
|
|
3000
3396
|
}
|
|
@@ -3011,7 +3407,8 @@ function analyzeEmail(html, framework) {
|
|
|
3011
3407
|
property: "@media",
|
|
3012
3408
|
message: `${client.name} does not support @media queries.`,
|
|
3013
3409
|
suggestion: sug.text,
|
|
3014
|
-
fix
|
|
3410
|
+
fix,
|
|
3411
|
+
fixType: getFixType("@media")
|
|
3015
3412
|
}, framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback("@media", client.id, framework)) ? { fixIsGenericFallback: true } : {}));
|
|
3016
3413
|
}
|
|
3017
3414
|
}
|
|
@@ -3050,7 +3447,8 @@ function analyzeEmail(html, framework) {
|
|
|
3050
3447
|
severity: "warning",
|
|
3051
3448
|
client: client.id,
|
|
3052
3449
|
property: prop,
|
|
3053
|
-
message: `${client.name} does not support "${prop}" in <style> blocks
|
|
3450
|
+
message: `${client.name} does not support "${prop}" in <style> blocks.`,
|
|
3451
|
+
fixType: getFixType(prop)
|
|
3054
3452
|
});
|
|
3055
3453
|
}
|
|
3056
3454
|
}
|
|
@@ -3064,9 +3462,13 @@ function analyzeEmail(html, framework) {
|
|
|
3064
3462
|
warnings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
3065
3463
|
return warnings;
|
|
3066
3464
|
}
|
|
3465
|
+
function getFixType(prop) {
|
|
3466
|
+
return STRUCTURAL_FIX_PROPERTIES.has(prop) ? "structural" : "css";
|
|
3467
|
+
}
|
|
3067
3468
|
function checkPropertySupport(prop, addWarning, framework) {
|
|
3068
3469
|
const supportData = CSS_SUPPORT[prop];
|
|
3069
3470
|
if (!supportData) return;
|
|
3471
|
+
const fixType = getFixType(prop);
|
|
3070
3472
|
for (const client of EMAIL_CLIENTS) {
|
|
3071
3473
|
const support = supportData[client.id] || "unknown";
|
|
3072
3474
|
if (support === "unsupported") {
|
|
@@ -3078,7 +3480,8 @@ function checkPropertySupport(prop, addWarning, framework) {
|
|
|
3078
3480
|
property: prop,
|
|
3079
3481
|
message: `${client.name} does not support "${prop}".`,
|
|
3080
3482
|
suggestion: sug.text,
|
|
3081
|
-
fix
|
|
3483
|
+
fix,
|
|
3484
|
+
fixType
|
|
3082
3485
|
}, framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback(prop, client.id, framework)) ? { fixIsGenericFallback: true } : {}));
|
|
3083
3486
|
} else if (support === "partial") {
|
|
3084
3487
|
const sug = getSuggestion(prop, client.id, framework);
|
|
@@ -3089,7 +3492,8 @@ function checkPropertySupport(prop, addWarning, framework) {
|
|
|
3089
3492
|
property: prop,
|
|
3090
3493
|
message: `${client.name} has partial support for "${prop}".`,
|
|
3091
3494
|
suggestion: sug.text,
|
|
3092
|
-
fix
|
|
3495
|
+
fix,
|
|
3496
|
+
fixType
|
|
3093
3497
|
}, framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback(prop, client.id, framework)) ? { fixIsGenericFallback: true } : {}));
|
|
3094
3498
|
}
|
|
3095
3499
|
}
|
|
@@ -3311,8 +3715,13 @@ ${originalHtml}
|
|
|
3311
3715
|
`;
|
|
3312
3716
|
for (const w of group) {
|
|
3313
3717
|
const clientName = (_f = (_e = getClient(w.client)) == null ? void 0 : _e.name) != null ? _f : w.client;
|
|
3718
|
+
const fixTypeLabel = w.fixType === "structural" ? " [STRUCTURAL]" : "";
|
|
3314
3719
|
issueSection += `
|
|
3315
|
-
- **${w.property}** (${clientName}): ${w.message}`;
|
|
3720
|
+
- **${w.property}** (${clientName})${fixTypeLabel}: ${w.message}`;
|
|
3721
|
+
if (w.fixType === "structural") {
|
|
3722
|
+
issueSection += `
|
|
3723
|
+
- **Fix type: structural** \u2014 CSS-only changes will NOT work. HTML restructuring required.`;
|
|
3724
|
+
}
|
|
3316
3725
|
if (w.suggestion) {
|
|
3317
3726
|
issueSection += `
|
|
3318
3727
|
- Suggestion: ${w.suggestion}`;
|
|
@@ -3343,17 +3752,1005 @@ ${originalHtml}
|
|
|
3343
3752
|
);
|
|
3344
3753
|
return sections.join("\n\n");
|
|
3345
3754
|
}
|
|
3755
|
+
|
|
3756
|
+
// src/token-utils.ts
|
|
3757
|
+
var CHARS_PER_TOKEN = 3.5;
|
|
3758
|
+
var OUTPUT_RATIO = 1.3;
|
|
3759
|
+
var DEFAULT_SYSTEM_PROMPT_TOKENS = 250;
|
|
3760
|
+
async function estimateAiFixTokens(options) {
|
|
3761
|
+
const _a = options, {
|
|
3762
|
+
maxInputTokens = 16e3,
|
|
3763
|
+
tokenCounter,
|
|
3764
|
+
systemPromptTokens = DEFAULT_SYSTEM_PROMPT_TOKENS
|
|
3765
|
+
} = _a, rest = __objRest(_a, [
|
|
3766
|
+
"maxInputTokens",
|
|
3767
|
+
"tokenCounter",
|
|
3768
|
+
"systemPromptTokens"
|
|
3769
|
+
]);
|
|
3770
|
+
const effectiveMaxTokens = maxInputTokens - systemPromptTokens;
|
|
3771
|
+
const { warnings: finalWarnings, truncated, removed } = truncateWarnings(
|
|
3772
|
+
rest.warnings,
|
|
3773
|
+
rest.originalHtml,
|
|
3774
|
+
effectiveMaxTokens,
|
|
3775
|
+
rest.scope,
|
|
3776
|
+
rest.selectedClientId
|
|
3777
|
+
);
|
|
3778
|
+
const prompt = generateFixPrompt(__spreadProps(__spreadValues({}, rest), { warnings: finalWarnings }));
|
|
3779
|
+
const promptChars = prompt.length;
|
|
3780
|
+
let promptTokens;
|
|
3781
|
+
if (tokenCounter) {
|
|
3782
|
+
const count = tokenCounter(prompt);
|
|
3783
|
+
promptTokens = count instanceof Promise ? await count : count;
|
|
3784
|
+
} else {
|
|
3785
|
+
promptTokens = heuristicTokenCount(prompt);
|
|
3786
|
+
}
|
|
3787
|
+
const inputTokens = promptTokens + systemPromptTokens;
|
|
3788
|
+
const estimatedOutputTokens = Math.ceil(
|
|
3789
|
+
heuristicTokenCount(rest.originalHtml) * OUTPUT_RATIO
|
|
3790
|
+
);
|
|
3791
|
+
const structuralCount = finalWarnings.filter(
|
|
3792
|
+
(w) => w.fixType === "structural"
|
|
3793
|
+
).length;
|
|
3794
|
+
return {
|
|
3795
|
+
inputTokens,
|
|
3796
|
+
estimatedOutputTokens,
|
|
3797
|
+
promptCharacters: promptChars,
|
|
3798
|
+
htmlCharacters: rest.originalHtml.length,
|
|
3799
|
+
warningCount: finalWarnings.length,
|
|
3800
|
+
structuralCount,
|
|
3801
|
+
truncated,
|
|
3802
|
+
warningsRemoved: removed,
|
|
3803
|
+
warnings: finalWarnings
|
|
3804
|
+
};
|
|
3805
|
+
}
|
|
3806
|
+
function heuristicTokenCount(text) {
|
|
3807
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
3808
|
+
}
|
|
3809
|
+
function truncateWarnings(warnings, html, maxTokens, scope, selectedClientId) {
|
|
3810
|
+
const originalCount = warnings.length;
|
|
3811
|
+
const fullPromptEstimate = heuristicTokenCount(html) + heuristicTokenCount(
|
|
3812
|
+
JSON.stringify(warnings)
|
|
3813
|
+
) + 500;
|
|
3814
|
+
if (fullPromptEstimate <= maxTokens) {
|
|
3815
|
+
return { warnings, truncated: false, removed: 0 };
|
|
3816
|
+
}
|
|
3817
|
+
let result = [...warnings];
|
|
3818
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3819
|
+
result = result.filter((w) => {
|
|
3820
|
+
const key = `${w.property}:${w.severity}`;
|
|
3821
|
+
if (seen.has(key)) return false;
|
|
3822
|
+
seen.add(key);
|
|
3823
|
+
return true;
|
|
3824
|
+
});
|
|
3825
|
+
if (estimateFits(result, html, maxTokens)) {
|
|
3826
|
+
return { warnings: result, truncated: true, removed: originalCount - result.length };
|
|
3827
|
+
}
|
|
3828
|
+
result = result.filter((w) => w.severity !== "info");
|
|
3829
|
+
if (estimateFits(result, html, maxTokens)) {
|
|
3830
|
+
return { warnings: result, truncated: true, removed: originalCount - result.length };
|
|
3831
|
+
}
|
|
3832
|
+
result = result.filter((w) => w.fixType === "structural" || w.severity === "error");
|
|
3833
|
+
if (estimateFits(result, html, maxTokens)) {
|
|
3834
|
+
return { warnings: result, truncated: true, removed: originalCount - result.length };
|
|
3835
|
+
}
|
|
3836
|
+
result = result.map((w) => __spreadProps(__spreadValues({}, w), {
|
|
3837
|
+
fix: w.fix ? __spreadProps(__spreadValues({}, w.fix), {
|
|
3838
|
+
before: w.fix.before.length > 200 ? w.fix.before.slice(0, 200) + "\n/* ... truncated ... */" : w.fix.before,
|
|
3839
|
+
after: w.fix.after.length > 200 ? w.fix.after.slice(0, 200) + "\n/* ... truncated ... */" : w.fix.after
|
|
3840
|
+
}) : void 0
|
|
3841
|
+
}));
|
|
3842
|
+
return { warnings: result, truncated: true, removed: originalCount - result.length };
|
|
3843
|
+
}
|
|
3844
|
+
function estimateFits(warnings, html, maxTokens) {
|
|
3845
|
+
const estimate = heuristicTokenCount(html) + heuristicTokenCount(JSON.stringify(warnings)) + 500;
|
|
3846
|
+
return estimate <= maxTokens;
|
|
3847
|
+
}
|
|
3848
|
+
|
|
3849
|
+
// src/ai-fix.ts
|
|
3850
|
+
async function generateAiFix(options) {
|
|
3851
|
+
const _a = options, { provider, maxInputTokens = 16e3 } = _a, promptOptions = __objRest(_a, ["provider", "maxInputTokens"]);
|
|
3852
|
+
const estimate = await estimateAiFixTokens(__spreadProps(__spreadValues({}, promptOptions), {
|
|
3853
|
+
maxInputTokens
|
|
3854
|
+
}));
|
|
3855
|
+
const truncatedWarnings = estimate.warnings;
|
|
3856
|
+
const prompt = generateFixPrompt(__spreadProps(__spreadValues({}, promptOptions), { warnings: truncatedWarnings }));
|
|
3857
|
+
const structuralCount = countStructuralWarnings(
|
|
3858
|
+
truncatedWarnings,
|
|
3859
|
+
promptOptions.scope,
|
|
3860
|
+
promptOptions.selectedClientId
|
|
3861
|
+
);
|
|
3862
|
+
const _b = estimate, { warnings: _discarded } = _b, tokenEstimate = __objRest(_b, ["warnings"]);
|
|
3863
|
+
const response = await provider(prompt);
|
|
3864
|
+
const code = extractCode(response);
|
|
3865
|
+
return {
|
|
3866
|
+
code,
|
|
3867
|
+
prompt,
|
|
3868
|
+
targetedWarnings: tokenEstimate.warningCount,
|
|
3869
|
+
structuralCount,
|
|
3870
|
+
tokenEstimate
|
|
3871
|
+
};
|
|
3872
|
+
}
|
|
3873
|
+
var AI_FIX_SYSTEM_PROMPT = `You are an expert email developer specializing in cross-client HTML email compatibility. You fix emails to render correctly across all email clients.
|
|
3874
|
+
|
|
3875
|
+
Rules:
|
|
3876
|
+
- Return ONLY the fixed code inside a single code fence. No explanations before or after.
|
|
3877
|
+
- Preserve all existing content, text, links, and visual design.
|
|
3878
|
+
- For structural issues (fixType: "structural"), you MUST restructure the HTML \u2014 CSS-only changes will not work.
|
|
3879
|
+
- Common structural patterns:
|
|
3880
|
+
- word-break/overflow-wrap unsupported \u2192 wrap text in <table><tr><td> with constrained width
|
|
3881
|
+
- display:flex/grid \u2192 convert to <table> layout (match the original column count and proportions)
|
|
3882
|
+
- border-radius in Outlook \u2192 use VML <v:roundrect> with <!--[if mso]> conditionals
|
|
3883
|
+
- background-image in Outlook \u2192 use VML <v:rect> with <v:fill>
|
|
3884
|
+
- max-width in Outlook \u2192 wrap in <!--[if mso]><table width="N"> conditional
|
|
3885
|
+
- position:absolute \u2192 use <table> cells for layout
|
|
3886
|
+
- <svg> \u2192 replace with <img> pointing to a hosted PNG
|
|
3887
|
+
- For CSS-only issues (fixType: "css"), swap properties or add fallbacks.
|
|
3888
|
+
- Apply ALL fixes from the issues list \u2014 do not skip any.
|
|
3889
|
+
- Use the framework syntax specified (JSX/MJML/Maizzle/HTML).
|
|
3890
|
+
- For JSX: use camelCase style props, React Email components, and proper TypeScript types.
|
|
3891
|
+
- For MJML: use mj-* elements and attributes.
|
|
3892
|
+
- For Maizzle: use Tailwind CSS classes.`;
|
|
3893
|
+
function countStructuralWarnings(warnings, scope, selectedClientId) {
|
|
3894
|
+
const filtered = scope === "current" && selectedClientId ? warnings.filter((w) => w.client === selectedClientId) : warnings;
|
|
3895
|
+
return filtered.filter((w) => w.fixType === "structural").length;
|
|
3896
|
+
}
|
|
3897
|
+
function extractCode(response) {
|
|
3898
|
+
const fencePattern = /```(?:[\w]*)\n([\s\S]*?)```/g;
|
|
3899
|
+
let largest = null;
|
|
3900
|
+
let match;
|
|
3901
|
+
while ((match = fencePattern.exec(response)) !== null) {
|
|
3902
|
+
const content = match[1].trim();
|
|
3903
|
+
if (largest === null || content.length > largest.length) {
|
|
3904
|
+
largest = content;
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
if (largest !== null) {
|
|
3908
|
+
return largest;
|
|
3909
|
+
}
|
|
3910
|
+
return response.trim();
|
|
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
|
+
}
|
|
3346
4734
|
export {
|
|
4735
|
+
AI_FIX_SYSTEM_PROMPT,
|
|
3347
4736
|
EMAIL_CLIENTS,
|
|
4737
|
+
STRUCTURAL_FIX_PROPERTIES,
|
|
3348
4738
|
analyzeEmail,
|
|
4739
|
+
analyzeImages,
|
|
4740
|
+
analyzeSpam,
|
|
4741
|
+
checkAccessibility,
|
|
3349
4742
|
diffResults,
|
|
4743
|
+
estimateAiFixTokens,
|
|
4744
|
+
generateAiFix,
|
|
3350
4745
|
generateCompatibilityScore,
|
|
3351
4746
|
generateFixPrompt,
|
|
3352
4747
|
getClient,
|
|
3353
4748
|
getCodeFix,
|
|
3354
4749
|
getSuggestion,
|
|
4750
|
+
heuristicTokenCount,
|
|
3355
4751
|
simulateDarkMode,
|
|
3356
4752
|
transformForAllClients,
|
|
3357
|
-
transformForClient
|
|
4753
|
+
transformForClient,
|
|
4754
|
+
validateLinks
|
|
3358
4755
|
};
|
|
3359
4756
|
//# sourceMappingURL=index.js.map
|