payload-plugin-newsletter 0.17.4 → 0.19.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/CHANGELOG.md CHANGED
@@ -1,3 +1,106 @@
1
+ ## [0.19.0] - 2025-07-30
2
+
3
+ ### Added
4
+ - **Responsive Email Design** - Complete overhaul of email template for mobile-first responsive design
5
+ - Mobile-optimized email template with responsive CSS media queries
6
+ - Responsive typography with proper mobile font sizes and line heights
7
+ - Mobile-friendly image scaling and spacing
8
+ - Dark mode support with `prefers-color-scheme` media queries
9
+ - Comprehensive email client compatibility (Outlook, Gmail, Apple Mail)
10
+ - Mobile-specific CSS classes for precise responsive control
11
+
12
+ ### Enhanced
13
+ - **Email Template Architecture**
14
+ - Completely redesigned `wrapInEmailTemplate` function with responsive structure
15
+ - Added proper viewport meta tags and email client compatibility headers
16
+ - Enhanced preheader text formatting with proper hiding techniques
17
+ - Improved table-based layout structure for email client consistency
18
+ - Better font rendering with `-webkit-font-smoothing` and `-moz-osx-font-smoothing`
19
+
20
+ - **Typography System**
21
+ - Responsive heading sizes that scale appropriately on mobile devices
22
+ - H1: 32px desktop → 24px mobile with optimized line-height
23
+ - H2: 24px desktop → 20px mobile with optimized line-height
24
+ - H3: 20px desktop → 16px mobile with optimized line-height
25
+ - Enhanced paragraph styling with consistent font-size and line-height
26
+ - Improved list styling with proper spacing and typography
27
+
28
+ - **Image Handling**
29
+ - Responsive images with `mobile-width-100` class for full-width scaling
30
+ - Enhanced image captions with mobile-optimized typography
31
+ - Proper image border-radius for modern email design
32
+ - Better image centering and spacing on all screen sizes
33
+
34
+ ### Technical Improvements
35
+ - **CSS Media Queries** - Comprehensive mobile-first responsive design
36
+ - `@media only screen and (max-width: 640px)` breakpoint
37
+ - Mobile utility classes: `.mobile-hide`, `.mobile-center`, `.mobile-width-100`
38
+ - Mobile padding classes: `.mobile-padding`, `.mobile-padding-sm`
39
+ - Mobile typography classes: `.mobile-font-size-14/16/20/24`
40
+ - Mobile spacing classes: `.mobile-margin-bottom-16/20`
41
+
42
+ - **Email Client Compatibility**
43
+ - Outlook-specific MSO conditional comments and fixes
44
+ - Apple Mail message reformatting prevention
45
+ - Gmail and other client table-based layout optimization
46
+ - Cross-client font fallback stack
47
+
48
+ - **Dark Mode Support**
49
+ - CSS custom properties for dark mode backgrounds and text
50
+ - Proper border color adjustments for dark themes
51
+ - Future-ready design system for theme customization
52
+
53
+ ### Breaking Changes
54
+ - None - all changes maintain backward compatibility with existing email content
55
+
56
+ ### Browser/Client Support
57
+ - ✅ Outlook 2016+ (Windows/Mac)
58
+ - ✅ Gmail (Web/Mobile/App)
59
+ - ✅ Apple Mail (macOS/iOS)
60
+ - ✅ Yahoo Mail
61
+ - ✅ Thunderbird
62
+ - ✅ Mobile email clients (iOS/Android)
63
+
64
+ ## [0.18.0] - 2025-07-30
65
+
66
+ ### Added
67
+ - **Media Field Population in Custom Blocks** - Fixed critical issue where media fields in custom blocks weren't populated during email conversion
68
+ - Added automatic media field population in preview endpoint before email conversion
69
+ - Media fields now receive full media objects with URLs instead of just ID strings
70
+ - Added `populateFields` option to `BroadcastCustomizations` interface for configurable field population
71
+ - Added recursive population support for array fields containing upload fields
72
+ - Comprehensive logging for media population success and failures
73
+ - Both email previews and sent emails now properly populate media relationships
74
+
75
+ ### Enhanced
76
+ - **Preview Endpoint** - Significantly improved custom block support
77
+ - Preview endpoint now populates media fields before calling custom block converters
78
+ - Custom blocks with images now display correctly in email previews
79
+ - Added detailed logging for debugging media population issues
80
+
81
+ - **Broadcast Sync Logic** - Applied media population to all broadcast operations
82
+ - Create operation now populates media fields before provider sync
83
+ - Update operation populates media fields when content changes
84
+ - Deferred create operation (empty → content) now handles media population
85
+ - Ensures consistency between previews and actual sent emails
86
+
87
+ ### Technical
88
+ - **New Helper Functions**
89
+ - `populateMediaFields()` - Recursively finds and populates upload fields in Lexical content
90
+ - `populateBlockMediaFields()` - Handles individual block media field population
91
+ - Support for both direct upload fields and upload fields within arrays
92
+ - Automatic detection of upload fields based on custom block configuration
93
+ - MongoDB ObjectId pattern matching for field population decisions
94
+
95
+ ### Types
96
+ - **Enhanced BroadcastCustomizations Interface**
97
+ - Added `populateFields` option with string array or function signature
98
+ - Comprehensive documentation with usage examples
99
+ - Support for block-type-specific field population logic
100
+
101
+ ### Breaking Changes
102
+ - None - all changes are backward compatible and additive
103
+
1
104
  ## [0.17.4] - 2025-07-30
2
105
 
3
106
  ### Fixed
@@ -1029,9 +1029,9 @@ async function convertParagraph(node, mediaUrl, customBlockConverter) {
1029
1029
  );
1030
1030
  const children = childParts.join("");
1031
1031
  if (!children.trim()) {
1032
- return '<p style="margin: 0 0 16px 0; min-height: 1em;">&nbsp;</p>';
1032
+ return '<p class="mobile-margin-bottom-16" style="margin: 0 0 16px 0; min-height: 1em;">&nbsp;</p>';
1033
1033
  }
1034
- return `<p style="margin: 0 0 16px 0; text-align: ${align};">${children}</p>`;
1034
+ return `<p class="mobile-margin-bottom-16" style="margin: 0 0 16px 0; text-align: ${align}; font-size: 16px; line-height: 1.5;">${children}</p>`;
1035
1035
  }
1036
1036
  async function convertHeading(node, mediaUrl, customBlockConverter) {
1037
1037
  const tag = node.tag || "h1";
@@ -1045,8 +1045,14 @@ async function convertHeading(node, mediaUrl, customBlockConverter) {
1045
1045
  h2: "font-size: 24px; font-weight: 600; margin: 0 0 16px 0; line-height: 1.3;",
1046
1046
  h3: "font-size: 20px; font-weight: 600; margin: 0 0 12px 0; line-height: 1.4;"
1047
1047
  };
1048
+ const mobileClasses = {
1049
+ h1: "mobile-font-size-24",
1050
+ h2: "mobile-font-size-20",
1051
+ h3: "mobile-font-size-16"
1052
+ };
1048
1053
  const style = `${styles2[tag] || styles2.h3} text-align: ${align};`;
1049
- return `<${tag} style="${style}">${children}</${tag}>`;
1054
+ const mobileClass = mobileClasses[tag] || mobileClasses.h3;
1055
+ return `<${tag} class="${mobileClass}" style="${style}">${children}</${tag}>`;
1050
1056
  }
1051
1057
  async function convertList(node, mediaUrl, customBlockConverter) {
1052
1058
  const tag = node.listType === "number" ? "ol" : "ul";
@@ -1054,8 +1060,8 @@ async function convertList(node, mediaUrl, customBlockConverter) {
1054
1060
  (node.children || []).map((child) => convertNode(child, mediaUrl, customBlockConverter))
1055
1061
  );
1056
1062
  const children = childParts.join("");
1057
- const style = tag === "ul" ? "margin: 0 0 16px 0; padding-left: 24px; list-style-type: disc;" : "margin: 0 0 16px 0; padding-left: 24px; list-style-type: decimal;";
1058
- return `<${tag} style="${style}">${children}</${tag}>`;
1063
+ const style = tag === "ul" ? "margin: 0 0 16px 0; padding-left: 24px; list-style-type: disc; font-size: 16px; line-height: 1.5;" : "margin: 0 0 16px 0; padding-left: 24px; list-style-type: decimal; font-size: 16px; line-height: 1.5;";
1064
+ return `<${tag} class="mobile-margin-bottom-16" style="${style}">${children}</${tag}>`;
1059
1065
  }
1060
1066
  async function convertListItem(node, mediaUrl, customBlockConverter) {
1061
1067
  const childParts = await Promise.all(
@@ -1112,16 +1118,16 @@ function convertUpload(node, mediaUrl) {
1112
1118
  }
1113
1119
  const alt = node.fields?.altText || upload.alt || "";
1114
1120
  const caption = node.fields?.caption || "";
1115
- const imgHtml = `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" />`;
1121
+ const imgHtml = `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}" class="mobile-width-100" style="max-width: 100%; height: auto; display: block; margin: 0 auto; border-radius: 6px;" />`;
1116
1122
  if (caption) {
1117
1123
  return `
1118
- <div style="margin: 0 0 16px 0; text-align: center;">
1124
+ <div style="margin: 0 0 16px 0; text-align: center;" class="mobile-margin-bottom-16">
1119
1125
  ${imgHtml}
1120
- <p style="margin: 8px 0 0 0; font-size: 14px; color: #6b7280; font-style: italic;">${escapeHtml(caption)}</p>
1126
+ <p style="margin: 8px 0 0 0; font-size: 14px; color: #6b7280; font-style: italic; text-align: center;" class="mobile-font-size-14">${escapeHtml(caption)}</p>
1121
1127
  </div>
1122
1128
  `;
1123
1129
  }
1124
- return `<div style="margin: 0 0 16px 0; text-align: center;">${imgHtml}</div>`;
1130
+ return `<div style="margin: 0 0 16px 0; text-align: center;" class="mobile-margin-bottom-16">${imgHtml}</div>`;
1125
1131
  }
1126
1132
  async function convertBlock(node, mediaUrl, customBlockConverter) {
1127
1133
  const blockType = node.fields?.blockName || node.blockName;
@@ -1194,11 +1200,14 @@ function escapeHtml(text) {
1194
1200
  }
1195
1201
  function wrapInEmailTemplate(content, preheader) {
1196
1202
  return `<!DOCTYPE html>
1197
- <html lang="en">
1203
+ <html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
1198
1204
  <head>
1199
1205
  <meta charset="UTF-8">
1200
1206
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1201
- <title>Email</title>
1207
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
1208
+ <meta name="x-apple-disable-message-reformatting">
1209
+ <title>Newsletter</title>
1210
+
1202
1211
  <!--[if mso]>
1203
1212
  <noscript>
1204
1213
  <xml>
@@ -1208,16 +1217,155 @@ function wrapInEmailTemplate(content, preheader) {
1208
1217
  </xml>
1209
1218
  </noscript>
1210
1219
  <![endif]-->
1220
+
1221
+ <style>
1222
+ /* Reset and base styles */
1223
+ * {
1224
+ -webkit-text-size-adjust: 100%;
1225
+ -ms-text-size-adjust: 100%;
1226
+ }
1227
+
1228
+ body {
1229
+ margin: 0 !important;
1230
+ padding: 0 !important;
1231
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
1232
+ font-size: 16px;
1233
+ line-height: 1.5;
1234
+ color: #1A1A1A;
1235
+ background-color: #f8f9fa;
1236
+ -webkit-font-smoothing: antialiased;
1237
+ -moz-osx-font-smoothing: grayscale;
1238
+ }
1239
+
1240
+ table {
1241
+ border-spacing: 0 !important;
1242
+ border-collapse: collapse !important;
1243
+ table-layout: fixed !important;
1244
+ margin: 0 auto !important;
1245
+ }
1246
+
1247
+ table table table {
1248
+ table-layout: auto;
1249
+ }
1250
+
1251
+ img {
1252
+ -ms-interpolation-mode: bicubic;
1253
+ max-width: 100%;
1254
+ height: auto;
1255
+ border: 0;
1256
+ outline: none;
1257
+ text-decoration: none;
1258
+ }
1259
+
1260
+ /* Responsive styles */
1261
+ @media only screen and (max-width: 640px) {
1262
+ .mobile-hide {
1263
+ display: none !important;
1264
+ }
1265
+
1266
+ .mobile-center {
1267
+ text-align: center !important;
1268
+ }
1269
+
1270
+ .mobile-width-100 {
1271
+ width: 100% !important;
1272
+ max-width: 100% !important;
1273
+ }
1274
+
1275
+ .mobile-padding {
1276
+ padding: 20px !important;
1277
+ }
1278
+
1279
+ .mobile-padding-sm {
1280
+ padding: 16px !important;
1281
+ }
1282
+
1283
+ .mobile-font-size-14 {
1284
+ font-size: 14px !important;
1285
+ }
1286
+
1287
+ .mobile-font-size-16 {
1288
+ font-size: 16px !important;
1289
+ }
1290
+
1291
+ .mobile-font-size-20 {
1292
+ font-size: 20px !important;
1293
+ line-height: 1.3 !important;
1294
+ }
1295
+
1296
+ .mobile-font-size-24 {
1297
+ font-size: 24px !important;
1298
+ line-height: 1.2 !important;
1299
+ }
1300
+
1301
+ /* Stack sections on mobile */
1302
+ .mobile-stack {
1303
+ display: block !important;
1304
+ width: 100% !important;
1305
+ }
1306
+
1307
+ /* Mobile-specific spacing */
1308
+ .mobile-margin-bottom-16 {
1309
+ margin-bottom: 16px !important;
1310
+ }
1311
+
1312
+ .mobile-margin-bottom-20 {
1313
+ margin-bottom: 20px !important;
1314
+ }
1315
+ }
1316
+
1317
+ /* Dark mode support */
1318
+ @media (prefers-color-scheme: dark) {
1319
+ .dark-mode-bg {
1320
+ background-color: #1a1a1a !important;
1321
+ }
1322
+
1323
+ .dark-mode-text {
1324
+ color: #ffffff !important;
1325
+ }
1326
+
1327
+ .dark-mode-border {
1328
+ border-color: #333333 !important;
1329
+ }
1330
+ }
1331
+
1332
+ /* Outlook-specific fixes */
1333
+ <!--[if mso]>
1334
+ <style>
1335
+ table {
1336
+ border-collapse: collapse;
1337
+ border-spacing: 0;
1338
+ border: none;
1339
+ margin: 0;
1340
+ }
1341
+
1342
+ div, p {
1343
+ margin: 0;
1344
+ }
1345
+ </style>
1346
+ <![endif]-->
1347
+ </style>
1211
1348
  </head>
1212
- <body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 16px; line-height: 1.5; color: #333333; background-color: #f3f4f6;">
1213
- ${preheader ? `<div style="display: none; max-height: 0; overflow: hidden;">${escapeHtml(preheader)}</div>` : ""}
1214
- <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin: 0; padding: 0;">
1349
+ <body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; font-size: 16px; line-height: 1.5; color: #1A1A1A; background-color: #f8f9fa;">
1350
+ ${preheader ? `
1351
+ <!-- Preheader text -->
1352
+ <div style="display: none; max-height: 0; overflow: hidden; font-size: 1px; line-height: 1px; color: transparent;">
1353
+ ${escapeHtml(preheader)}
1354
+ </div>
1355
+ ` : ""}
1356
+
1357
+ <!-- Main container -->
1358
+ <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin: 0; padding: 0; background-color: #f8f9fa;">
1215
1359
  <tr>
1216
- <td align="center" style="padding: 20px 0;">
1217
- <table role="presentation" cellpadding="0" cellspacing="0" width="600" style="margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden;">
1360
+ <td align="center" style="padding: 20px 10px;">
1361
+ <!-- Email wrapper -->
1362
+ <table role="presentation" cellpadding="0" cellspacing="0" width="600" class="mobile-width-100" style="margin: 0 auto; max-width: 600px;">
1218
1363
  <tr>
1219
- <td style="padding: 40px 30px;">
1220
- ${content}
1364
+ <td class="mobile-padding" style="padding: 0;">
1365
+ <!-- Content area with light background -->
1366
+ <div style="background-color: #ffffff; padding: 40px 30px; border-radius: 8px;" class="mobile-padding">
1367
+ ${content}
1368
+ </div>
1221
1369
  </td>
1222
1370
  </tr>
1223
1371
  </table>
@@ -1625,6 +1773,86 @@ var createTestBroadcastEndpoint = (config, collectionSlug) => {
1625
1773
  };
1626
1774
 
1627
1775
  // src/endpoints/broadcasts/preview.ts
1776
+ async function populateMediaFields(content, payload, config) {
1777
+ if (!content || typeof content !== "object") return content;
1778
+ if (content.root?.children) {
1779
+ for (const child of content.root.children) {
1780
+ await populateBlockMediaFields(child, payload, config);
1781
+ }
1782
+ }
1783
+ return content;
1784
+ }
1785
+ async function populateBlockMediaFields(node, payload, config) {
1786
+ if (node.type === "block" && node.fields) {
1787
+ const blockType = node.fields.blockType || node.fields.blockName;
1788
+ const customBlocks = config.customizations?.broadcasts?.customBlocks || [];
1789
+ const blockConfig = customBlocks.find((b) => b.slug === blockType);
1790
+ if (blockConfig && blockConfig.fields) {
1791
+ for (const field of blockConfig.fields) {
1792
+ if (field.type === "upload" && field.relationTo && node.fields[field.name]) {
1793
+ const fieldValue = node.fields[field.name];
1794
+ if (typeof fieldValue === "string" && fieldValue.match(/^[a-f0-9]{24}$/i)) {
1795
+ try {
1796
+ const media = await payload.findByID({
1797
+ collection: field.relationTo,
1798
+ id: fieldValue,
1799
+ depth: 0
1800
+ });
1801
+ if (media) {
1802
+ node.fields[field.name] = media;
1803
+ payload.logger?.info(`Populated ${field.name} for block ${blockType}:`, {
1804
+ mediaId: fieldValue,
1805
+ mediaUrl: media.url,
1806
+ filename: media.filename
1807
+ });
1808
+ }
1809
+ } catch (error) {
1810
+ payload.logger?.error(`Failed to populate ${field.name} for block ${blockType}:`, error);
1811
+ }
1812
+ }
1813
+ }
1814
+ if (field.type === "array" && field.fields) {
1815
+ const arrayValue = node.fields[field.name];
1816
+ if (Array.isArray(arrayValue)) {
1817
+ for (const arrayItem of arrayValue) {
1818
+ if (arrayItem && typeof arrayItem === "object") {
1819
+ for (const arrayField of field.fields) {
1820
+ if (arrayField.type === "upload" && arrayField.relationTo && arrayItem[arrayField.name]) {
1821
+ const arrayFieldValue = arrayItem[arrayField.name];
1822
+ if (typeof arrayFieldValue === "string" && arrayFieldValue.match(/^[a-f0-9]{24}$/i)) {
1823
+ try {
1824
+ const media = await payload.findByID({
1825
+ collection: arrayField.relationTo,
1826
+ id: arrayFieldValue,
1827
+ depth: 0
1828
+ });
1829
+ if (media) {
1830
+ arrayItem[arrayField.name] = media;
1831
+ payload.logger?.info(`Populated array ${arrayField.name} for block ${blockType}:`, {
1832
+ mediaId: arrayFieldValue,
1833
+ mediaUrl: media.url,
1834
+ filename: media.filename
1835
+ });
1836
+ }
1837
+ } catch (error) {
1838
+ payload.logger?.error(`Failed to populate array ${arrayField.name} for block ${blockType}:`, error);
1839
+ }
1840
+ }
1841
+ }
1842
+ }
1843
+ }
1844
+ }
1845
+ }
1846
+ }
1847
+ }
1848
+ }
1849
+ }
1850
+ if (node.children) {
1851
+ for (const child of node.children) {
1852
+ await populateBlockMediaFields(child, payload, config);
1853
+ }
1854
+ }
1855
+ }
1628
1856
  var createBroadcastPreviewEndpoint = (config, _collectionSlug) => {
1629
1857
  return {
1630
1858
  path: "/preview",
@@ -1640,7 +1868,9 @@ var createBroadcastPreviewEndpoint = (config, _collectionSlug) => {
1640
1868
  }, { status: 400 });
1641
1869
  }
1642
1870
  const mediaUrl = req.payload.config.serverURL ? `${req.payload.config.serverURL}/api/media` : "/api/media";
1643
- const htmlContent = await convertToEmailSafeHtml(content, {
1871
+ req.payload.logger?.info("Populating media fields for email preview...");
1872
+ const populatedContent = await populateMediaFields(content, req.payload, config);
1873
+ const htmlContent = await convertToEmailSafeHtml(populatedContent, {
1644
1874
  wrapInTemplate: true,
1645
1875
  preheader,
1646
1876
  mediaUrl,
@@ -1939,8 +2169,9 @@ var createBroadcastsCollection = (pluginConfig) => {
1939
2169
  }
1940
2170
  const { BroadcastApiProvider: BroadcastApiProvider2 } = await Promise.resolve().then(() => (init_broadcast2(), broadcast_exports));
1941
2171
  const provider = new BroadcastApiProvider2(providerConfig);
1942
- req.payload.logger.info("Converting content to HTML...");
1943
- const htmlContent = await convertToEmailSafeHtml(doc.contentSection?.content, {
2172
+ req.payload.logger.info("Populating media fields and converting content to HTML...");
2173
+ const populatedContent = await populateMediaFields(doc.contentSection?.content, req.payload, pluginConfig);
2174
+ const htmlContent = await convertToEmailSafeHtml(populatedContent, {
1944
2175
  customBlockConverter: pluginConfig.customizations?.broadcasts?.customBlockConverter
1945
2176
  });
1946
2177
  if (!htmlContent || htmlContent.trim() === "") {
@@ -2039,7 +2270,8 @@ var createBroadcastsCollection = (pluginConfig) => {
2039
2270
  return doc;
2040
2271
  }
2041
2272
  req.payload.logger.info("Creating broadcast in provider (deferred from initial create)...");
2042
- const htmlContent = await convertToEmailSafeHtml(doc.contentSection?.content, {
2273
+ const populatedContent = await populateMediaFields(doc.contentSection?.content, req.payload, pluginConfig);
2274
+ const htmlContent = await convertToEmailSafeHtml(populatedContent, {
2043
2275
  customBlockConverter: pluginConfig.customizations?.broadcasts?.customBlockConverter
2044
2276
  });
2045
2277
  if (!htmlContent || htmlContent.trim() === "") {
@@ -2100,7 +2332,8 @@ var createBroadcastsCollection = (pluginConfig) => {
2100
2332
  updates.preheader = doc.contentSection?.preheader;
2101
2333
  }
2102
2334
  if (JSON.stringify(doc.contentSection?.content) !== JSON.stringify(previousDoc?.contentSection?.content)) {
2103
- updates.content = await convertToEmailSafeHtml(doc.contentSection?.content, {
2335
+ const populatedContent = await populateMediaFields(doc.contentSection?.content, req.payload, pluginConfig);
2336
+ updates.content = await convertToEmailSafeHtml(populatedContent, {
2104
2337
  customBlockConverter: pluginConfig.customizations?.broadcasts?.customBlockConverter
2105
2338
  });
2106
2339
  }