@designsystemsinternational/email 0.0.2 → 0.0.3
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/bin/commands/index.js +5 -0
- package/bin/commands/mailchimp.js +57 -0
- package/bin/commands/s3.js +57 -0
- package/bin/commands/scaffold.js +22 -0
- package/bin/commands/send.js +69 -0
- package/bin/commands/zip.js +39 -0
- package/bin/defaultConfig.js +39 -0
- package/dist/cjs/browser.cjs +368 -0
- package/dist/cjs/index.cjs +631 -0
- package/dist/esm/browser.js +361 -0
- package/dist/esm/index.js +620 -0
- package/dist/index.cjs +526 -0
- package/dist/index.js +361 -0
- package/package.json +23 -5
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import posthtml from 'posthtml';
|
|
2
|
+
import removeTags from 'posthtml-remove-tags';
|
|
3
|
+
import removeAttributes from 'posthtml-remove-attributes';
|
|
4
|
+
import matchHelper from 'posthtml-match-helper';
|
|
5
|
+
import colorConvert from 'color-convert';
|
|
6
|
+
import unescape from 'lodash.unescape';
|
|
7
|
+
import compressing from 'compressing';
|
|
8
|
+
import getStream from 'get-stream';
|
|
9
|
+
import mailchimpAPI from '@mailchimp/mailchimp_marketing';
|
|
10
|
+
import AWS from 'aws-sdk';
|
|
11
|
+
import nodemailer from 'nodemailer';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generates a deterministic random string based on the input string.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} str Input string
|
|
17
|
+
* @return {string} hash of str
|
|
18
|
+
*/
|
|
19
|
+
const hashFromString = (str) => {
|
|
20
|
+
let hash = 0;
|
|
21
|
+
for (let i = 0; i < str.length; i++) {
|
|
22
|
+
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
23
|
+
}
|
|
24
|
+
return hash.toString();
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Formats a string to a filename by turning it into kebab case
|
|
29
|
+
*
|
|
30
|
+
* @param {string} text
|
|
31
|
+
* @return {string} formatted filename
|
|
32
|
+
*/
|
|
33
|
+
const formatFilename = (text) => {
|
|
34
|
+
return text
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9-]+/g, ' ')
|
|
37
|
+
.trim()
|
|
38
|
+
.replace(/\s+/g, '-');
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Converts all rgb defined colors with their hex equivalent
|
|
43
|
+
* as some email clients only support hex color format.
|
|
44
|
+
*/
|
|
45
|
+
const convertRgbToHex = () => {
|
|
46
|
+
return (tree) => {
|
|
47
|
+
tree.match(matchHelper('[style]'), (node) => {
|
|
48
|
+
let styleBlock = node.attrs.style;
|
|
49
|
+
styleBlock = styleBlock.replace(
|
|
50
|
+
/rgb\(\s*\d{1,3},\s*\d{1,3},\s*\d{1,3}\)/g,
|
|
51
|
+
(match) => {
|
|
52
|
+
const values = match
|
|
53
|
+
.replace(/[rgb() ]/g, '')
|
|
54
|
+
.split(',')
|
|
55
|
+
.map((n) => parseInt(n));
|
|
56
|
+
return '#' + colorConvert.rgb.hex(values);
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
node.attrs.style = styleBlock;
|
|
60
|
+
return node;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return tree;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Prepares HTML by only returning the body tag, to make sure
|
|
69
|
+
*/
|
|
70
|
+
const onlyReturnBody = () => {
|
|
71
|
+
return (tree) => {
|
|
72
|
+
tree.match({ tag: 'body' }, (node) => {
|
|
73
|
+
return node.content;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return tree;
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Rewrites the markup by turning all conditional comments returned from the
|
|
82
|
+
* <ConditionalComment> component into true conditional comments that can be
|
|
83
|
+
* picked up by Outlook.
|
|
84
|
+
*/
|
|
85
|
+
const injectConditionalComments = () => {
|
|
86
|
+
return (tree) => {
|
|
87
|
+
tree.match(matchHelper('[data-conditional-comment]'), (node) => {
|
|
88
|
+
const comment = node.attrs['data-conditional-comment'];
|
|
89
|
+
|
|
90
|
+
// Remove HTML comment delimiters
|
|
91
|
+
const cleanedComment = unescape(comment).replace(/<!--|-->/g, '');
|
|
92
|
+
|
|
93
|
+
// Remove Line breaks from comment
|
|
94
|
+
const cleanedCommentLines = cleanedComment.split('\n').join('');
|
|
95
|
+
|
|
96
|
+
node = `<!--${cleanedCommentLines}-->`;
|
|
97
|
+
return node;
|
|
98
|
+
});
|
|
99
|
+
return tree;
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Rewrites the markup by adding all styles defined in the data-mso-style
|
|
105
|
+
* attribute to the <style> tag. This is needed because the mso styles are
|
|
106
|
+
* non-standard CSS, so they cannot be added to React's style tag.
|
|
107
|
+
*/
|
|
108
|
+
const injectMsoStyles = () => {
|
|
109
|
+
return (tree) => {
|
|
110
|
+
tree.match(matchHelper('[data-mso-style]'), (node) => {
|
|
111
|
+
if (node.attrs?.style == null) node.attrs.style = '';
|
|
112
|
+
node.attrs.style += node.attrs['data-mso-style'];
|
|
113
|
+
node.attrs['data-mso-style'] = null;
|
|
114
|
+
return node;
|
|
115
|
+
});
|
|
116
|
+
return tree;
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Collects all inline styles defined in the components, de-dedupes them and
|
|
122
|
+
* moves them to a single unified style tag in the head of the document.
|
|
123
|
+
*/
|
|
124
|
+
const moveInlineStylesToHead = () => {
|
|
125
|
+
let styles = {};
|
|
126
|
+
|
|
127
|
+
return (tree) => {
|
|
128
|
+
tree.match({ tag: 'style' }, (node) => {
|
|
129
|
+
if (!node.content) return '';
|
|
130
|
+
const styleBlock = node.content[0].trim();
|
|
131
|
+
const hash = hashFromString(styleBlock);
|
|
132
|
+
|
|
133
|
+
if (styleBlock) {
|
|
134
|
+
styles[hash] = styleBlock;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Remove the inline style node
|
|
138
|
+
return '';
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
tree.match({ tag: 'head' }, (node) => {
|
|
142
|
+
node.content.push({
|
|
143
|
+
tag: 'style',
|
|
144
|
+
content: Object.values(styles).join(''),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return node;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return tree;
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const prepareHtmlFactory = (plugins) => {
|
|
155
|
+
const htmlProcessor = posthtml();
|
|
156
|
+
|
|
157
|
+
plugins.forEach((plugin) => {
|
|
158
|
+
htmlProcessor.use(plugin);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return async (body, template = (str) => str) => {
|
|
162
|
+
const content = template(body);
|
|
163
|
+
|
|
164
|
+
const processed = await htmlProcessor.process(content);
|
|
165
|
+
|
|
166
|
+
return processed.html;
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* This default pipeline removes all script and link tags from the HTML,
|
|
172
|
+
* moves all inline styles to the head and wraps everything in the email template.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} body - the html body
|
|
175
|
+
* @param {function} template - the template function, defaults to no template
|
|
176
|
+
*/
|
|
177
|
+
const defaultPipeline = prepareHtmlFactory([
|
|
178
|
+
onlyReturnBody(),
|
|
179
|
+
removeTags({ tags: ['script', 'link'] }),
|
|
180
|
+
convertRgbToHex(),
|
|
181
|
+
moveInlineStylesToHead(),
|
|
182
|
+
injectMsoStyles(),
|
|
183
|
+
injectConditionalComments(),
|
|
184
|
+
removeAttributes(['data-reactid', 'data-reactroot']),
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
const BASE64_REGEX = /data:image\/([a-zA-Z]*);base64,(.*)/;
|
|
188
|
+
|
|
189
|
+
// NODE.JS packacking factory
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Turns base64 inlined images into image buffers and updates their src
|
|
193
|
+
* attribute accordingly. Returns an object containing the updated html and
|
|
194
|
+
* an array of image buffers. Different adapters can then hook into this to
|
|
195
|
+
* process the email into a ZIP archive or pushing it to an API.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} body - the prepared html body
|
|
198
|
+
* @return {object} the processed html and buffers for all images
|
|
199
|
+
*/
|
|
200
|
+
const preparePackageFactory =
|
|
201
|
+
({ urlPrefix = '', handlePackage }) =>
|
|
202
|
+
async (body, opts = {}) => {
|
|
203
|
+
let counter = 0;
|
|
204
|
+
const images = [];
|
|
205
|
+
|
|
206
|
+
const processed = await posthtml()
|
|
207
|
+
.use((tree) => {
|
|
208
|
+
tree.match({ tag: 'img' }, (node) => {
|
|
209
|
+
const { src, alt } = node.attrs;
|
|
210
|
+
|
|
211
|
+
if (BASE64_REGEX.test(src)) {
|
|
212
|
+
const [_, ext, data] = src.match(
|
|
213
|
+
/data:image\/([a-zA-Z]*);base64,(.*)/,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const hash = Math.random().toString(36).substring(2, 7);
|
|
217
|
+
|
|
218
|
+
const filename = alt
|
|
219
|
+
? `${formatFilename(alt)}-text-${hash}.${ext}`
|
|
220
|
+
: `image-${counter}.${ext}`;
|
|
221
|
+
|
|
222
|
+
node.attrs.src = `${urlPrefix}${filename}`;
|
|
223
|
+
|
|
224
|
+
const imgBuffer = Buffer.from(data, 'base64');
|
|
225
|
+
|
|
226
|
+
images.push({
|
|
227
|
+
filename,
|
|
228
|
+
buffer: imgBuffer,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
counter++;
|
|
232
|
+
}
|
|
233
|
+
return node;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return tree;
|
|
237
|
+
})
|
|
238
|
+
.process(body);
|
|
239
|
+
|
|
240
|
+
return await handlePackage(
|
|
241
|
+
{
|
|
242
|
+
html: processed.html,
|
|
243
|
+
images,
|
|
244
|
+
},
|
|
245
|
+
opts,
|
|
246
|
+
);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const makeDefaultTemplate =
|
|
250
|
+
({ lang = 'en-US', dir = 'ltr' } = {}) =>
|
|
251
|
+
(body) =>
|
|
252
|
+
`<!doctype html>
|
|
253
|
+
<html
|
|
254
|
+
lang="${lang}"
|
|
255
|
+
xml:lang="${lang}"
|
|
256
|
+
dir="${dir}"
|
|
257
|
+
xmlns="http://www.w3.org/1999/xhtml"
|
|
258
|
+
xmlns:v="urn:schemas-microsoft-com:vml"
|
|
259
|
+
xmlns:o="urn:schemas-microsoft-com:office:office"
|
|
260
|
+
>
|
|
261
|
+
<head>
|
|
262
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
263
|
+
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
264
|
+
<title>Chief</title>
|
|
265
|
+
<!--[if mso]>
|
|
266
|
+
<style type="text/css">
|
|
267
|
+
table {
|
|
268
|
+
border-collapse: collapse;
|
|
269
|
+
border: 0;
|
|
270
|
+
border-spacing: 0;
|
|
271
|
+
margin: 0;
|
|
272
|
+
mso-table-lspace: 0pt !important;
|
|
273
|
+
mso-table-rspace: 0pt !important;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
img {
|
|
277
|
+
display: block !important;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
div, td {padding:0;}
|
|
281
|
+
|
|
282
|
+
div {margin:0 !important;}
|
|
283
|
+
|
|
284
|
+
.notMso {
|
|
285
|
+
display: none;
|
|
286
|
+
mso-hide: all;
|
|
287
|
+
}
|
|
288
|
+
</style>
|
|
289
|
+
<noscript>
|
|
290
|
+
<xml>
|
|
291
|
+
<o:OfficeDocumentSettings>
|
|
292
|
+
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
293
|
+
</o:OfficeDocumentSettings>
|
|
294
|
+
</xml>
|
|
295
|
+
</noscript>
|
|
296
|
+
<![endif]-->
|
|
297
|
+
<meta name="x-apple-disable-message-reformatting" />
|
|
298
|
+
<meta httpEquiv="Content-Type" content="text/html; charset=utf-8" />
|
|
299
|
+
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
|
300
|
+
<meta name="viewport" content="width=device-width" />
|
|
301
|
+
<meta name="color-scheme" content="light" />
|
|
302
|
+
<meta name="supported-color-schemes" content="light" />
|
|
303
|
+
|
|
304
|
+
<meta name="format-detection" content="telephone=no">
|
|
305
|
+
<meta name="format-detection" content="date=no">
|
|
306
|
+
<meta name="format-detection" content="address=no">
|
|
307
|
+
<meta name="format-detection" content="email=no">
|
|
308
|
+
|
|
309
|
+
<style>
|
|
310
|
+
a[x-apple-data-detectors] {
|
|
311
|
+
color: inherit !important;
|
|
312
|
+
|
|
313
|
+
font-size: inherit !important;
|
|
314
|
+
font-family: inherit !important;
|
|
315
|
+
font-weight: inherit !important;
|
|
316
|
+
line-height: inherit !important;
|
|
317
|
+
text-decoration: inherit !important;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#MessageViewBody a:not([style]) {
|
|
321
|
+
color: inherit !important;
|
|
322
|
+
font-size: inherit !important;
|
|
323
|
+
font-family: inherit !important;
|
|
324
|
+
font-weight: inherit !important;
|
|
325
|
+
line-height: inherit !important;
|
|
326
|
+
text-decoration: inherit !important;
|
|
327
|
+
}
|
|
328
|
+
</style>
|
|
329
|
+
</head>
|
|
330
|
+
<body class="body" style="padding:0;">
|
|
331
|
+
${body}
|
|
332
|
+
</body>
|
|
333
|
+
</html>`;
|
|
334
|
+
|
|
335
|
+
const defaultTemplate = makeDefaultTemplate();
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Turns an HTML email into a ZIP package and saves that ZIP file to disk.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} body - the prepared html body
|
|
341
|
+
* @return {Buffer} the zip file buffer
|
|
342
|
+
*/
|
|
343
|
+
const packageToZip = preparePackageFactory({
|
|
344
|
+
urlPrefix: '',
|
|
345
|
+
handlePackage: async ({ html, images }) => {
|
|
346
|
+
const zipStream = new compressing.zip.Stream();
|
|
347
|
+
const htmlEntry = Buffer.from(html, 'utf8');
|
|
348
|
+
zipStream.addEntry(htmlEntry, { relativePath: `./index.html` });
|
|
349
|
+
|
|
350
|
+
images.forEach(({ filename, buffer }) => {
|
|
351
|
+
zipStream.addEntry(buffer, { relativePath: `./${filename}` });
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const zipBuffer = await getStream.buffer(zipStream);
|
|
355
|
+
return zipBuffer;
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const defaultOptions = {
|
|
360
|
+
mailchimpId: null,
|
|
361
|
+
mailchimpAPIKey: null,
|
|
362
|
+
mailchimpServerPrefix: null,
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const getIdForCampaign = async (campaignId, mailchimpApi, offset = 0) => {
|
|
366
|
+
if (!campaignId) throw new Error('No campaign ID provided');
|
|
367
|
+
|
|
368
|
+
const count = 100;
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
const response = await mailchimpApi.campaigns.list({
|
|
372
|
+
count,
|
|
373
|
+
offset,
|
|
374
|
+
sortField: 'create_time',
|
|
375
|
+
sortDir: 'DESC',
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const campaign = response.campaigns.find(
|
|
379
|
+
(campaign) => campaign.web_id == campaignId,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
if (!campaign && response.total_items > count + offset) {
|
|
383
|
+
return await getIdForCampaign(campaignId, offset + count);
|
|
384
|
+
}
|
|
385
|
+
if (!campaign) {
|
|
386
|
+
throw new Error(`No mailchimp campaign found for ${campaignId}`);
|
|
387
|
+
}
|
|
388
|
+
if (['sending', 'sent'].includes(campaign.status)) {
|
|
389
|
+
throw new Error("Campaign already sent. Can't update content ¯_(ツ)_/¯");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return { campaignId: campaign?.id };
|
|
393
|
+
} catch (e) {
|
|
394
|
+
throw new Error(e.message);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const publish = async (campaignId, zipFile, mailchimpApi) => {
|
|
399
|
+
if (!campaignId) throw new Error('No campaign ID provided');
|
|
400
|
+
if (!zipFile) throw new Error('No zip file provided');
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
const response = await mailchimpApi.campaigns.setContent(campaignId, {
|
|
404
|
+
archive: {
|
|
405
|
+
archive_type: 'tar',
|
|
406
|
+
archive_content: zipFile,
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
return response;
|
|
411
|
+
} catch (e) {
|
|
412
|
+
throw new Error(e.message);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const prepareEmailToTarBuffer = async (html, images) => {
|
|
417
|
+
const tarStream = new compressing.tar.Stream();
|
|
418
|
+
|
|
419
|
+
const htmlEntry = Buffer.from(html, 'utf8');
|
|
420
|
+
tarStream.addEntry(htmlEntry, { relativePath: `./index.html` });
|
|
421
|
+
|
|
422
|
+
images.forEach(({ filename, buffer }) => {
|
|
423
|
+
tarStream.addEntry(buffer, { relativePath: `./${filename}` });
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const tarBuffer = await getStream.buffer(tarStream);
|
|
427
|
+
|
|
428
|
+
return tarBuffer.toString('base64');
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Publishes the eamil to mailchimp
|
|
433
|
+
*
|
|
434
|
+
* @param {string} body - the prepared html body
|
|
435
|
+
* @param {object} options - the options object containing { mailchimpId, mailchimpAPIKey, mailchimpServerPrefix }
|
|
436
|
+
*/
|
|
437
|
+
const packageToMailchimp = preparePackageFactory({
|
|
438
|
+
urlPrefix: '',
|
|
439
|
+
handlePackage: async ({ html, images }, opts) => {
|
|
440
|
+
const config = Object.assign({}, defaultOptions, opts);
|
|
441
|
+
|
|
442
|
+
const { mailchimpId, mailchimpAPIKey } = config;
|
|
443
|
+
if (mailchimpId === null) throw new Error('mailchimpId is required');
|
|
444
|
+
if (mailchimpAPIKey === null) throw new Error('mailchimpAPIKey is quired');
|
|
445
|
+
|
|
446
|
+
const mailchimpServerPrefix =
|
|
447
|
+
config.mailchimpServerPrefix || mailchimpAPIKey.split('-').pop();
|
|
448
|
+
|
|
449
|
+
mailchimpAPI.setConfig({
|
|
450
|
+
apiKey: mailchimpAPIKey,
|
|
451
|
+
server: mailchimpServerPrefix,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const { campaignId } = await getIdForCampaign(mailchimpId, mailchimpAPI);
|
|
455
|
+
const tarBuffer = await prepareEmailToTarBuffer(html, images);
|
|
456
|
+
await publish(campaignId, tarBuffer, mailchimpAPI);
|
|
457
|
+
|
|
458
|
+
return campaignId;
|
|
459
|
+
},
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Extracts the images from the HTML and publishes the package to S3
|
|
464
|
+
* Returns the HTML with all image URLs updated to point to S3
|
|
465
|
+
*
|
|
466
|
+
* @param {string} body - the prepared html body
|
|
467
|
+
* @param {object} options - the options object containing { awsAccessKeyId, awsSecretAccessKey, awsBucket, url, prefix }
|
|
468
|
+
* @return {string} HTML of the mail with all image URLs updated to point to S3
|
|
469
|
+
*/
|
|
470
|
+
const packageToS3 = async (body, opts = {}) => {
|
|
471
|
+
const {
|
|
472
|
+
awsAccessKeyId,
|
|
473
|
+
awsSecretAccessKey,
|
|
474
|
+
awsBucket,
|
|
475
|
+
url,
|
|
476
|
+
prefix = '',
|
|
477
|
+
} = opts;
|
|
478
|
+
|
|
479
|
+
if (!awsAccessKeyId) throw new Error('awsAccessKeyId is required');
|
|
480
|
+
if (!awsSecretAccessKey) throw new Error('awsSecretAccessKey is required');
|
|
481
|
+
if (!awsBucket) throw new Error('awsBucket is required');
|
|
482
|
+
|
|
483
|
+
const s3 = new AWS.S3({
|
|
484
|
+
accessKeyId: awsAccessKeyId,
|
|
485
|
+
secretAccessKey: awsSecretAccessKey,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
const uploadFileToS3 = async (buffer, filename) => {
|
|
489
|
+
await s3
|
|
490
|
+
.upload({
|
|
491
|
+
Bucket: awsBucket,
|
|
492
|
+
Key: `${prefix}${filename}`,
|
|
493
|
+
Body: buffer,
|
|
494
|
+
ACL: 'public-read',
|
|
495
|
+
CacheControl: 'no-store',
|
|
496
|
+
})
|
|
497
|
+
.promise();
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const runUpload = preparePackageFactory({
|
|
501
|
+
urlPrefix: `${url}${prefix}`,
|
|
502
|
+
handlePackage: async ({ html, images }) => {
|
|
503
|
+
const htmlBuffer = Buffer.from(html, 'utf8');
|
|
504
|
+
await uploadFileToS3(htmlBuffer, 'index.html');
|
|
505
|
+
|
|
506
|
+
for (const image of images) {
|
|
507
|
+
await uploadFileToS3(image.buffer, image.filename);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return html;
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
return await runUpload(body);
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const sendEmailSes = async (
|
|
518
|
+
{ fromName, fromEmail, toName, toEmail, subject, plainBody, htmlBody },
|
|
519
|
+
opts = {},
|
|
520
|
+
) => {
|
|
521
|
+
const { awsAccessKeyId, awsSecretAccessKey } = opts;
|
|
522
|
+
|
|
523
|
+
if (!awsAccessKeyId) throw new Error('awsAccessKeyId is required');
|
|
524
|
+
if (!awsSecretAccessKey) throw new Error('awsSecretAccessKey is required');
|
|
525
|
+
|
|
526
|
+
const fromAddress = fromName ? `"${fromName}" <${fromEmail}>` : fromEmail;
|
|
527
|
+
const toAddress = toName ? `"${toName}" <${toEmail}>` : toEmail;
|
|
528
|
+
|
|
529
|
+
const ses = new AWS.SES({
|
|
530
|
+
accessKeyId: awsAccessKeyId,
|
|
531
|
+
secretAccessKey: awsSecretAccessKey,
|
|
532
|
+
region: 'us-east-1',
|
|
533
|
+
httpOptions: { timeout: 5000 },
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const emailRes = await ses
|
|
537
|
+
.sendEmail({
|
|
538
|
+
Source: fromAddress,
|
|
539
|
+
Destination: {
|
|
540
|
+
ToAddresses: [toAddress],
|
|
541
|
+
},
|
|
542
|
+
Message: {
|
|
543
|
+
Subject: {
|
|
544
|
+
Charset: 'UTF-8',
|
|
545
|
+
Data: subject,
|
|
546
|
+
},
|
|
547
|
+
Body: {
|
|
548
|
+
Html: {
|
|
549
|
+
Charset: 'UTF-8',
|
|
550
|
+
Data: htmlBody,
|
|
551
|
+
},
|
|
552
|
+
Text: {
|
|
553
|
+
Charset: 'UTF-8',
|
|
554
|
+
Data: plainBody,
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
})
|
|
559
|
+
.promise();
|
|
560
|
+
if (!emailRes.MessageId) {
|
|
561
|
+
throw new Error('Failed to send email');
|
|
562
|
+
} else {
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Turns nodemailer's callback style API into a promise
|
|
569
|
+
*/
|
|
570
|
+
const performSend = async (transporter, message) => {
|
|
571
|
+
return new Promise((resolve, reject) => {
|
|
572
|
+
transporter.sendMail(message, (error, info) => {
|
|
573
|
+
if (error) {
|
|
574
|
+
reject(error);
|
|
575
|
+
} else {
|
|
576
|
+
resolve(info);
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Sends an email via SMTP
|
|
584
|
+
*
|
|
585
|
+
* @param {object} email - The email object { fromName, fromEmail, toName, toEmail, subject, plainBody, htmlBody }
|
|
586
|
+
* @param {object} options - The options object { smtpHost, smtpPort, smtpUser, smtpPass, useTLS }
|
|
587
|
+
*/
|
|
588
|
+
const sendEmailSMTP = async (
|
|
589
|
+
{ fromName, fromEmail, toName, toEmail, subject, plainBody, htmlBody },
|
|
590
|
+
{ smtpHost, smtpPort = 25, smtpUser, smtpPass, useTLS = true } = {},
|
|
591
|
+
) => {
|
|
592
|
+
if (!smtpHost) throw new Error('SMTP host is required');
|
|
593
|
+
if (!smtpUser) throw new Error('SMTP user is required');
|
|
594
|
+
if (!smtpPass) throw new Error('SMTP pass is required');
|
|
595
|
+
|
|
596
|
+
const fromAddress = fromName ? `"${fromName}" <${fromEmail}>` : fromEmail;
|
|
597
|
+
const toAddress = toName ? `"${toName}" <${toEmail}>` : toEmail;
|
|
598
|
+
|
|
599
|
+
const transporter = nodemailer.createTransport({
|
|
600
|
+
host: smtpHost,
|
|
601
|
+
port: smtpPort,
|
|
602
|
+
secure: useTLS,
|
|
603
|
+
auth: {
|
|
604
|
+
user: smtpUser,
|
|
605
|
+
pass: smtpPass,
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
const message = {
|
|
610
|
+
from: fromAddress,
|
|
611
|
+
to: toAddress,
|
|
612
|
+
subject,
|
|
613
|
+
text: plainBody,
|
|
614
|
+
html: htmlBody,
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
await performSend(transporter, message);
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
export { defaultTemplate, makeDefaultTemplate, packageToMailchimp, packageToS3, packageToZip, defaultPipeline as prepareHtml, prepareHtmlFactory, preparePackageFactory, sendEmailSMTP, sendEmailSes };
|