Skip to content

Commit 47f129f

Browse files
committed
feat: added caching support, added fallbackDir and fallbackPrefix
1 parent f6e22f8 commit 47f129f

File tree

4 files changed

+103
-29
lines changed

4 files changed

+103
-29
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ Accepts the following arguments and returns a [Nodemailer plugin][nodemailer-plu
8181
* `aws` (Object) **Required** - configuration options for Amazon Web Services
8282
* `params` (Object) **Required**
8383
* `Bucket` (String) **Required** - AWS Bucket Name
84+
* `fallbackDir` (String) - a fallback directory to write to in case Amazon S3 upload fails (automatically set to `os.tmpdir()` otherwise if `NODE_ENV` is production then it is set to false and disabled) - you may want to specify the full path to your build directory so files are stored there (e.g. `fallbackDir: path.join(__dirname, 'build', 'img', 'nodemailer')`)
85+
* `fallbackPrefix` (String or Boolean) - the prefix to use for relative paths, e.g. you don't want to have `file:///some/tmp/dir/foo.png`, but you want to have `https://example.com/img/foo.png` instead - so specify that prefix here (e.g. `fallbackPrefix: 'http://localhost:3000/img/nodemailer/'` if you have a build directory `fallbackDir` of `path.join(__dirname, 'build', 'img', 'nodemailer')` and `path.join(__dirname, 'build')` is being served by your web server). The default value is `false` and therefore `file:///` relative path will be used instead.
86+
* `logger` (Object) - a logger to use in the event of an error while uploading to S3 (defaults to `console`)
8487

8588

8689
## Gmail Example

index.js

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,37 @@
1-
const { promisify } = require('util');
1+
const fs = require('fs');
2+
const os = require('os');
3+
const path = require('path');
24
const zlib = require('zlib');
5+
const { promisify } = require('util');
6+
7+
const AWS = require('aws-sdk');
8+
const Lipo = require('lipo');
9+
const _ = require('lodash');
10+
const debug = require('debug')('nodemailer-base64-to-s3');
311
const isSANB = require('is-string-and-not-blank');
412
const mime = require('mime-types');
5-
const _ = require('lodash');
613
const ms = require('ms');
7-
const AWS = require('aws-sdk');
814
const revHash = require('rev-hash');
9-
const Lipo = require('lipo');
1015

1116
const regexp = new RegExp(
1217
/(<img[\s\S]*? src=")data:(image\/(?:png|jpe?g|gif|svg\+xml));base64,([\s\S]*?)("[\s\S]*?>)/g
1318
);
14-
19+
const PROD = process.env.NODE_ENV === 'production';
20+
const mkdir = promisify(fs.mkdir).bind(fs);
21+
const writeFile = promisify(fs.writeFile).bind(fs);
1522
const gzip = promisify(zlib.gzip).bind(zlib);
23+
const cache = {};
1624

1725
const base64ToS3 = opts => {
1826
// set defaults
1927
opts = _.defaults(opts, {
2028
aws: {},
2129
maxAge: ms('1yr'),
2230
dir: '/',
23-
cloudFrontDomainName: process.env.AWS_CLOUDFRONT_DOMAIN || ''
31+
cloudFrontDomainName: process.env.AWS_CLOUDFRONT_DOMAIN || '',
32+
fallbackDir: process.env.NODE_ENV === 'production' ? false : os.tmpdir(),
33+
fallbackPrefix: false,
34+
logger: console
2435
});
2536

2637
if (!_.isNumber(opts.maxAge))
@@ -34,6 +45,12 @@ const base64ToS3 = opts => {
3445

3546
// prepare AWS upload using config
3647
const s3 = new AWS.S3(opts.aws);
48+
49+
// we cannot currently use this since it does not return a promise
50+
// <https://github.com/aws/aws-sdk-js/pull/1079>
51+
// await s3obj.upload({ Body }).promise();
52+
//
53+
// so instead we use promisify to convert it to a promise
3754
const upload = promisify(s3.upload).bind(s3);
3855

3956
async function compile(mail, fn) {
@@ -79,28 +96,34 @@ const base64ToS3 = opts => {
7996
}
8097

8198
async function transformImage({ original, start, mimeType, base64, end }) {
82-
// create a buffer of the base64 image
83-
// and convert it to a png
84-
let buffer = Buffer.from(base64, 'base64');
85-
8699
// get the image extension
87100
let extension = mime.extension(mimeType);
88-
89101
// convert and optimize the image if it is an SVG file
90-
if (extension === 'svg') {
102+
if (extension === 'svg') extension = 'png';
103+
// if we already cached the base64 then return it
104+
const hash = revHash(`${extension}:${base64}`);
105+
let buffer;
106+
if (cache[hash]) {
107+
buffer = cache[hash];
108+
debug(`hitting cache for ${hash}`);
109+
} else {
110+
// create a buffer of the base64 image
111+
// and convert it to a png
112+
buffer = Buffer.from(base64, 'base64');
91113
const lipo = new Lipo();
92114
buffer = await lipo(buffer)
93115
.png()
94116
.toBuffer();
95-
extension = 'png';
117+
cache[hash] = buffer;
96118
}
97119

98120
// apply transformation and gzip file
99121
const Body = await gzip(buffer);
100122

101123
// generate random filename
102124
// get the file extension based on mimeType
103-
const Key = `${opts.dir}${revHash(base64)}.${extension}`;
125+
const fileName = `${hash}.${extension}`;
126+
const Key = `${opts.dir}${fileName}`;
104127

105128
const obj = {
106129
Key,
@@ -111,18 +134,38 @@ const base64ToS3 = opts => {
111134
ContentType: 'image/png'
112135
};
113136

114-
// we cannot currently use this since it does not return a promise
115-
// <https://github.com/aws/aws-sdk-js/pull/1079>
116-
// await s3obj.upload({ Body }).promise();
117-
//
118-
// so instead we use promisify to convert it to a promise
119-
const data = await upload(obj);
137+
// use a fallback dir if the upload fails
138+
// but only if the environment is not production
139+
try {
140+
const data = cache[Key] ? cache[Key] : await upload(obj);
141+
if (cache[Key]) debug(`hitting cache for ${Key}`);
142+
143+
const replacement = isSANB(opts.cloudFrontDomainName)
144+
? `${start}https://${opts.cloudFrontDomainName}/${data.key}${end}`
145+
: `${start}${data.Location}${end}`;
120146

121-
const replacement = isSANB(opts.cloudFrontDomainName)
122-
? `${start}https://${opts.cloudFrontDomainName}/${data.key}${end}`
123-
: `${start}${data.Location}${end}`;
147+
cache[Key] = data;
124148

125-
return [original, replacement];
149+
return [original, replacement];
150+
} catch (err) {
151+
// fallback in case upload to S3 fails for whatever reason
152+
if (opts.fallbackDir) {
153+
if (PROD) opts.logger.error(err);
154+
if (!_.isString(opts.fallbackPrefix) && PROD)
155+
throw new Error(
156+
'fallbackPrefix was not specified, you cannot use file:/// in production mode'
157+
);
158+
const filePath = path.join(opts.fallbackDir, fileName);
159+
await mkdir(opts.fallbackDir, { recursive: true });
160+
await writeFile(filePath, buffer);
161+
const replacement = _.isString(opts.fallbackPrefix)
162+
? `${start}${opts.fallbackPrefix}${fileName}${end}`
163+
: `${start}file://${filePath}${end}`;
164+
return [original, replacement];
165+
}
166+
167+
throw err;
168+
}
126169
}
127170

128171
return compile;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
],
2222
"dependencies": {
2323
"aws-sdk": "^2.528.0",
24+
"debug": "^4.1.1",
2425
"is-string-and-not-blank": "^0.0.2",
2526
"lipo": "^0.0.10",
2627
"lodash": "^4.17.15",

test/test.js

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
const path = require('path');
2-
const test = require('ava');
3-
const nodemailer = require('nodemailer');
4-
const ms = require('ms');
5-
const dotenv = require('dotenv');
2+
63
const cheerio = require('cheerio');
7-
const validator = require('validator');
4+
const dotenv = require('dotenv');
85
const imageToUri = require('image-to-uri');
6+
const ms = require('ms');
7+
const nodemailer = require('nodemailer');
8+
const test = require('ava');
9+
const validator = require('validator');
10+
911
const base64ToS3 = require('..');
1012

1113
const html = {};
@@ -81,3 +83,28 @@ Object.keys(html).forEach(key => {
8183
t.is(url, clone);
8284
});
8385
});
86+
87+
test('writes to a fallback directory if AWS upload failed (e.g. no bucket param)', async t => {
88+
const customTransport = nodemailer.createTransport({ jsonTransport: true });
89+
customTransport.use(
90+
'compile',
91+
base64ToS3({
92+
maxAge: ms('1d')
93+
})
94+
);
95+
const res = await customTransport.sendMail({
96+
html: html.png,
97+
subject: 'subject',
98+
to: 'niftylettuce@gmail.com',
99+
from: 'niftylettuce@gmail.com'
100+
});
101+
102+
const message = JSON.parse(res.message);
103+
const $ = cheerio.load(message.html);
104+
t.true(
105+
validator.isURL($('img').attr('src'), {
106+
protocols: ['file'],
107+
require_host: false
108+
})
109+
);
110+
});

0 commit comments

Comments
 (0)