mirror of
https://github.com/dawidd6/action-send-mail.git
synced 2026-09-09 05:56:37 +07:00
d35571df19
Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
354 lines
15 KiB
JavaScript
354 lines
15 KiB
JavaScript
'use strict';
|
|
|
|
const shared = require('../shared');
|
|
const MimeNode = require('../mime-node');
|
|
const mimeFuncs = require('../mime-funcs');
|
|
|
|
// Only an own key counts as already set. `key in obj` also matches every member of
|
|
// Object.prototype, which silently drops a transporter default legitimately named
|
|
// toString or constructor.
|
|
const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
|
|
class MailMessage {
|
|
constructor(mailer, data) {
|
|
this.mailer = mailer;
|
|
this.data = {};
|
|
this.message = null;
|
|
|
|
data = data || {};
|
|
const options = mailer.options || {};
|
|
const defaults = mailer._defaults || {};
|
|
|
|
shared.copyOwnKeys(this.data, data);
|
|
|
|
this.data.headers = this.data.headers || {};
|
|
|
|
// Apply defaults. `_defaults` is caller supplied too, it is the second argument of
|
|
// createTransport, so it needs the same treatment as `data` above
|
|
shared.copyOwnKeys(this.data, defaults, key => hasOwn(this.data, key));
|
|
|
|
// headers is a special case. Allow setting individual default headers
|
|
shared.copyOwnKeys(this.data.headers, defaults.headers, key => hasOwn(this.data.headers, key));
|
|
|
|
// force specific keys from transporter options
|
|
['disableFileAccess', 'disableUrlAccess', 'normalizeHeaderKey', 'maxRecipients'].forEach(key => {
|
|
if (key in options) {
|
|
this.data[key] = options[key];
|
|
}
|
|
});
|
|
|
|
// The access flags are a sandbox rather than a message field, so `defaults` counts as
|
|
// transporter configuration for them. For a transporter plugin it is the only channel
|
|
// there is, createTransport leaves `options` undefined for one, and the defaults copy
|
|
// above yields to anything the message already set, which let message data switch the
|
|
// sandbox back off. Closing is one way here, same as in resolveContent below: either
|
|
// side may switch a flag on, neither can switch off what the other closed.
|
|
['disableFileAccess', 'disableUrlAccess'].forEach(key => {
|
|
if (!(key in options) && hasOwn(defaults, key)) {
|
|
this.data[key] = this.data[key] || defaults[key];
|
|
}
|
|
});
|
|
}
|
|
|
|
resolveContent(data, key, options, callback) {
|
|
// Most plugins call this with the legacy (data, key, callback) signature, which carries
|
|
// no access policy. The policy belongs to the message, so apply it here. Explicit
|
|
// options may only tighten it, never reopen what the transporter closed.
|
|
if (!callback && typeof options === 'function') {
|
|
callback = options;
|
|
options = false;
|
|
}
|
|
options = options || {};
|
|
|
|
const policy = {
|
|
disableFileAccess: this.data.disableFileAccess || options.disableFileAccess,
|
|
disableUrlAccess: this.data.disableUrlAccess || options.disableUrlAccess
|
|
};
|
|
|
|
return shared.resolveContent(data, key, policy, callback);
|
|
}
|
|
|
|
resolveAll(callback) {
|
|
const keys = [
|
|
[this.data, 'html'],
|
|
[this.data, 'text'],
|
|
[this.data, 'watchHtml'],
|
|
[this.data, 'amp'],
|
|
[this.data, 'icalEvent']
|
|
];
|
|
|
|
if (this.data.alternatives && this.data.alternatives.length) {
|
|
this.data.alternatives.forEach((alternative, i) => {
|
|
keys.push([this.data.alternatives, i]);
|
|
});
|
|
}
|
|
|
|
if (this.data.attachments && this.data.attachments.length) {
|
|
this.data.attachments.forEach((attachment, i) => {
|
|
if (!attachment.filename) {
|
|
attachment.filename =
|
|
(attachment.path || attachment.href || '').split('/').pop().split('?').shift() || 'attachment-' + (i + 1);
|
|
if (attachment.filename.indexOf('.') < 0) {
|
|
attachment.filename += '.' + mimeFuncs.detectExtension(attachment.contentType);
|
|
}
|
|
}
|
|
|
|
if (!attachment.contentType) {
|
|
attachment.contentType = mimeFuncs.detectMimeType(attachment.filename || attachment.path || attachment.href || 'bin');
|
|
}
|
|
|
|
keys.push([this.data.attachments, i]);
|
|
});
|
|
}
|
|
|
|
const mimeNode = new MimeNode();
|
|
|
|
const addressKeys = ['from', 'to', 'cc', 'bcc', 'sender', 'replyTo'];
|
|
|
|
addressKeys.forEach(address => {
|
|
let value;
|
|
if (this.message) {
|
|
value = [].concat(mimeNode._parseAddresses(this.message.getHeader(address === 'replyTo' ? 'reply-to' : address)) || []);
|
|
} else if (this.data[address]) {
|
|
value = [].concat(mimeNode._parseAddresses(this.data[address]) || []);
|
|
}
|
|
if (value && value.length) {
|
|
this.data[address] = value;
|
|
} else if (address in this.data) {
|
|
this.data[address] = null;
|
|
}
|
|
});
|
|
|
|
const singleKeys = ['from', 'sender'];
|
|
singleKeys.forEach(address => {
|
|
if (this.data[address]) {
|
|
this.data[address] = this.data[address].shift();
|
|
}
|
|
});
|
|
|
|
let pos = 0;
|
|
const resolveNext = () => {
|
|
if (pos >= keys.length) {
|
|
return callback(null, this.data);
|
|
}
|
|
const args = keys[pos++];
|
|
if (!args[0] || !args[0][args[1]]) {
|
|
return resolveNext();
|
|
}
|
|
shared.resolveContent(
|
|
...args,
|
|
{ disableFileAccess: this.data.disableFileAccess, disableUrlAccess: this.data.disableUrlAccess },
|
|
(err, value) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
const node = {
|
|
content: value
|
|
};
|
|
if (args[0][args[1]] && typeof args[0][args[1]] === 'object' && !Buffer.isBuffer(args[0][args[1]])) {
|
|
// The keys are the caller's, so copying them takes the same "__proto__"
|
|
// rule as the constructor. `key in node` stays as the already-set test
|
|
// here, unlike for the defaults: it also skips the Object.prototype
|
|
// member names, and letting message data land a `toString` string on a
|
|
// node only buys a TypeError the first time something stringifies it.
|
|
shared.copyOwnKeys(node, args[0][args[1]], key => key in node || ['content', 'path', 'href', 'raw'].includes(key));
|
|
}
|
|
|
|
args[0][args[1]] = node;
|
|
resolveNext();
|
|
}
|
|
);
|
|
};
|
|
|
|
setImmediate(() => resolveNext());
|
|
}
|
|
|
|
normalize(callback) {
|
|
const envelope = this.message.getEnvelope();
|
|
const messageId = this.message.messageId();
|
|
|
|
this.resolveAll((err, data) => {
|
|
if (err) {
|
|
return callback(err);
|
|
}
|
|
|
|
data.envelope = envelope;
|
|
data.messageId = messageId;
|
|
|
|
['html', 'text', 'watchHtml', 'amp'].forEach(key => {
|
|
if (data[key] && data[key].content) {
|
|
if (typeof data[key].content === 'string') {
|
|
data[key] = data[key].content;
|
|
} else if (Buffer.isBuffer(data[key].content)) {
|
|
data[key] = data[key].content.toString();
|
|
}
|
|
}
|
|
});
|
|
|
|
if (data.icalEvent && Buffer.isBuffer(data.icalEvent.content)) {
|
|
data.icalEvent.content = data.icalEvent.content.toString('base64');
|
|
data.icalEvent.encoding = 'base64';
|
|
}
|
|
|
|
if (data.alternatives && data.alternatives.length) {
|
|
data.alternatives.forEach(alternative => {
|
|
if (alternative && alternative.content && Buffer.isBuffer(alternative.content)) {
|
|
alternative.content = alternative.content.toString('base64');
|
|
alternative.encoding = 'base64';
|
|
}
|
|
});
|
|
}
|
|
|
|
if (data.attachments && data.attachments.length) {
|
|
data.attachments.forEach(attachment => {
|
|
if (attachment && attachment.content && Buffer.isBuffer(attachment.content)) {
|
|
attachment.content = attachment.content.toString('base64');
|
|
attachment.encoding = 'base64';
|
|
}
|
|
});
|
|
}
|
|
|
|
data.normalizedHeaders = {};
|
|
Object.keys(data.headers || {}).forEach(key => {
|
|
if (shared.isProtoKey(key)) {
|
|
return;
|
|
}
|
|
let value = [].concat(data.headers[key] || []).shift();
|
|
value = (value && value.value) || value;
|
|
if (value) {
|
|
if (['references', 'in-reply-to', 'message-id', 'content-id'].includes(key)) {
|
|
value = this.message._encodeHeaderValue(key, value);
|
|
}
|
|
data.normalizedHeaders[key] = value;
|
|
}
|
|
});
|
|
|
|
if (data.list && typeof data.list === 'object') {
|
|
const listHeaders = this._getListHeaders(data.list);
|
|
listHeaders.forEach(entry => {
|
|
data.normalizedHeaders[entry.key] = entry.value.map(val => (val && val.value) || val).join(', ');
|
|
});
|
|
}
|
|
|
|
if (data.references) {
|
|
data.normalizedHeaders.references = this.message._encodeHeaderValue('references', data.references);
|
|
}
|
|
|
|
if (data.inReplyTo) {
|
|
data.normalizedHeaders['in-reply-to'] = this.message._encodeHeaderValue('in-reply-to', data.inReplyTo);
|
|
}
|
|
|
|
return callback(null, data);
|
|
});
|
|
}
|
|
|
|
setMailerHeader() {
|
|
if (!this.message || !this.data.xMailer) {
|
|
return;
|
|
}
|
|
this.message.setHeader('X-Mailer', this.data.xMailer);
|
|
}
|
|
|
|
setPriorityHeaders() {
|
|
if (!this.message || !this.data.priority) {
|
|
return;
|
|
}
|
|
switch ((this.data.priority || '').toString().toLowerCase()) {
|
|
case 'high':
|
|
this.message.setHeader('X-Priority', '1 (Highest)');
|
|
this.message.setHeader('X-MSMail-Priority', 'High');
|
|
this.message.setHeader('Importance', 'High');
|
|
break;
|
|
case 'low':
|
|
this.message.setHeader('X-Priority', '5 (Lowest)');
|
|
this.message.setHeader('X-MSMail-Priority', 'Low');
|
|
this.message.setHeader('Importance', 'Low');
|
|
break;
|
|
default:
|
|
// do not add anything, since all messages are 'Normal' by default
|
|
}
|
|
}
|
|
|
|
setListHeaders() {
|
|
if (!this.message || !this.data.list || typeof this.data.list !== 'object') {
|
|
return;
|
|
}
|
|
// add optional List-* headers
|
|
this._getListHeaders(this.data.list).forEach(listHeader => {
|
|
listHeader.value.forEach(value => {
|
|
this.message.addHeader(listHeader.key, value);
|
|
});
|
|
});
|
|
}
|
|
|
|
_getListHeaders(listData) {
|
|
// make sure an url looks like <protocol:url>
|
|
return Object.keys(listData).map(key => ({
|
|
key: 'list-' + key.toLowerCase().trim(),
|
|
value: [].concat(listData[key] || []).map(value => ({
|
|
prepared: true,
|
|
foldLines: true,
|
|
value: []
|
|
.concat(value || [])
|
|
.map(value => {
|
|
if (typeof value === 'string') {
|
|
value = {
|
|
url: value
|
|
};
|
|
}
|
|
|
|
if (value && value.url) {
|
|
// strip CR/LF so a comment can't inject extra header lines. DEL is neither
|
|
// qtext nor ctext, so it can not be carried literally by either construct
|
|
// and has to become an encoded word like any other non-plaintext value
|
|
let comment = (value.comment || '').toString().replace(/\r?\n|\r/g, ' ');
|
|
const needsEncoding = !mimeFuncs.isPlainText(comment) || /\x7f/.test(comment);
|
|
|
|
if (key.toLowerCase().trim() === 'id') {
|
|
// List-ID: "comment" <domain>, where an unescaped quote or a trailing
|
|
// backslash in the comment would swallow the <domain> behind it
|
|
comment = needsEncoding ? mimeFuncs.encodeWord(comment) : mimeFuncs.quoteString(comment);
|
|
|
|
// List-ID expects a bare domain-like identifier, so strip the
|
|
// scheme prefix that _formatListUrl adds or passes through
|
|
return (
|
|
(value.comment ? comment + ' ' : '') + this._formatListUrl(value.url).replace(/^<[^:]+:\/{0,2}/, '<')
|
|
);
|
|
}
|
|
|
|
// List-*: <http://domain> (comment)
|
|
// the ctext specials go out as quoted-pairs, otherwise a ")" closes the
|
|
// comment early and leaves the rest as junk, an unpaired "(" opens a
|
|
// nested comment that never closes, and a trailing backslash escapes
|
|
// the closing ")" so the comment swallows whatever follows it
|
|
comment = needsEncoding ? mimeFuncs.encodeWord(comment) : comment.replace(/[()\\]/g, '\\$&');
|
|
|
|
return this._formatListUrl(value.url) + (value.comment ? ' (' + comment + ')' : '');
|
|
}
|
|
|
|
return '';
|
|
})
|
|
.filter(value => value)
|
|
.join(', ')
|
|
}))
|
|
}));
|
|
}
|
|
|
|
_formatListUrl(url) {
|
|
// a url has no way to carry a control char or DEL, and the angle brackets around it
|
|
// are not a quoting construct, so anything left here lands in the header raw
|
|
url = url.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').replace(/[\s<]+|[\s>]+/g, '');
|
|
if (/^(https?|mailto|ftp):/.test(url)) {
|
|
return '<' + url + '>';
|
|
}
|
|
if (/^[^@]+@[^@]+$/.test(url)) {
|
|
return '<mailto:' + url + '>';
|
|
}
|
|
|
|
return '<http://' + url + '>';
|
|
}
|
|
}
|
|
|
|
module.exports = MailMessage;
|