UNPKG

total5

Version:
928 lines (720 loc) 21.3 kB
// Total.js SMTP sender // The MIT License // Copyright 2016-2023 (c) Peter Širka <petersirka@gmail.com> const CRLF = '\r\n'; const REG_ESMTP = /\besmtp\b/i; const REG_STATE = /\d+/; const REG_WINLINE = /\r\n/g; const REG_NEWLINE = /\n/g; const REG_AUTH = /(AUTH LOGIN|AUTH PLAIN|PLAIN LOGIN|XOAUTH2|XOAUTH)/i; const REG_TLS = /TLS/; const REG_STARTTLS = /STARTTLS/; const ATTACHMENT = { encoding: 'base64' }; var INDEXATTACHMENT = 0; const CRLF_BUFFER = Buffer.from(CRLF); const CONCAT = [null, null]; var Mailer = {}; Mailer.debug = false; Mailer.Message = Message; Mailer.Mail = Message; Mailer.connections = {}; Mailer.create = (subject, body) => new Message(subject, body); F.TUtils.EventEmitter2.extend(Mailer); function Message(subject, body) { var self = this; self.subject = subject || ''; self.body = body || ''; self.type = 'html'; self.files; self.email_to = []; self.email_reply; self.email_cc; self.email_bcc; self.email_from = ''; self.closed = false; self.tls = false; self.$callback; // t.headers; // t.$unsubscribe; } Message.prototype.preview = function(val) { this.$preview = val; return this; }; Message.prototype.unsubscribe = function(url) { var tmp = url.substring(0, 6); this.$unsubscribe = url ? (tmp === 'http:/' || tmp === 'https:' ? '<' + url + '>' : '<mailto:' + url + '>') : null; return this; }; Message.prototype.callback = function(fn) { this.$callback = fn; return this; }; Message.prototype.sender = Message.prototype.from = function(email, name) { this.email_from = email; this.email_from_name = name; return this; }; Message.prototype.high = function() { this.$priority = 1; return this; }; Message.prototype.low = function() { this.$priority = 5; return this; }; Message.prototype.confidential = function() { this.$confidential = true; return this; }; Message.prototype.to = function(value, clear) { var self = this; if (clear) self.email_to.length = 0; self.email_to.push(value); return self; }; Message.prototype.cc = function(value, clear) { var self = this; if (clear || !self.email_cc) self.email_cc = []; self.email_cc.push(value); return self; }; Message.prototype.bcc = function(value, clear) { var self = this; if (clear || !self.email_bcc) self.email_bcc = []; self.email_bcc.push(value); return self; }; Message.prototype.reply = function(value, clear) { var self = this; if (clear || !self.email_reply) self.email_reply = []; self.email_reply.push(value); return self; }; Message.prototype.attachment = function(filename, name, contentid) { var self = this; var type; var ext; if (name) { ext = F.TUtils.getExtension(name); type = F.TUtils.contentTypes[ext]; } var obj = {}; obj.name = name; obj.filename = filename; obj.type = type; obj.ext = ext; if (contentid) { obj.disposition = 'inline'; obj.contentid = contentid; } if (!self.attachments) self.attachments = []; self.attachments.push(obj); return self; }; Message.prototype.attachmentfs = function(storage, id, name, contentid) { var self = this; var ext; var type; if (name) { ext = F.TUtils.getExtension(name); type = F.TUtils.contentTypes[ext]; } var obj = {}; obj.storage = storage; obj.name = name; obj.filename = id; obj.type = type; obj.ext = ext; if (contentid) { obj.disposition = 'inline'; obj.contentid = contentid; } if (!self.attachments) self.attachments = []; self.attachments.push(obj); return self; }; Message.prototype.manually = function() { this.$sending && clearImmediate(this.$sending); return this; }; Message.prototype.send2 = function(callback) { var self = this; if (F.config.$tapi && F.config.$tapimail) { var data = {}; data.to = []; for (let m of self.email_to) data.to.push(m); if (self.email_cc && self.email_cc.length) { data.cc = []; for (let m of self.email_cc) data.cc.push(m); } if (self.email_bcc && self.email_bcc.length) { data.bcc = []; for (let m of self.email_bcc) data.bcc.push(m); } if (self.email_reply && self.email_reply.length) { data.reply = []; for (let m of self.email_reply) data.reply.push(m); } data.from = self.email_from; data.subject = self.subject; data.type = self.type; data.body = self.body; data.priority = self.$priotity; data.unsubscribe = self.$unsubscribe; data.confidential = self.$confidential; F.api('TAPI/mail', data).callback(callback || NOOP); return; } for (let key in F.temporary.smtp) { if (!F.config.smtp || F.config.smtp.server !== key) Mailer.destroy(F.temporary.smtp[key]); } Mailer.send(F.config.smtp, self, callback); }; Message.prototype.send = function(options, callback) { var self = this; self.$callback2 = callback; Mailer.send(options, self, callback); return self; }; Mailer.tls = function(obj, opt) { var self = this; obj.tls = true; obj.socket.removeAllListeners(); var tlsoptions = { socket: obj.socket, host: obj.socket.$host, ciphers: 'SSLv3' }; for (let key in opt.tls) tlsoptions[key] = opt.tls[key]; obj.socket2 = F.Tls.connect(tlsoptions, () => self.$send(obj, opt, true)); obj.socket2.on('error', function(err) { Mailer.destroy(obj); self.closed = true; self.callback && self.callback(err); self.callback = null; if (obj.try || err.stack.indexOf('ECONNRESET') !== -1) return; Mailer.$events.error && Mailer.emit('error', err, obj); }); obj.socket2.on('clientError', function(err) { Mailer.destroy(obj); self.callback && self.callback(err); self.callback = null; Mailer.$events.error && !obj.try && Mailer.emit('error', err, obj); }); obj.socket2.on('connect', () => !opt.secure && self.$send(obj, opt)); }; Mailer.destroy = function(obj) { var self = this; if (obj.destroyed) return self; obj.destroyed = true; obj.closed = true; if (obj.socket) { obj.socket.removeAllListeners(); obj.socket.end(); obj.socket.destroy(); obj.socket = null; } if (obj.socket2) { obj.socket2.removeAllListeners(); obj.socket2.end(); obj.socket2.destroy(); obj.socket2 = null; } if (obj === F.temporary.smtp[obj.server]) delete F.temporary.smtp[obj.server]; delete self.connections[obj.id]; return self; }; Mailer.$writeattachment = function(obj) { var attachment = obj.files ? obj.files.shift() : false; if (!attachment) { Mailer.$writeline(obj, '--' + obj.boundary + '--', '', '.'); if (obj.callback) { obj.callback(null, obj.instance); obj.callback = null; } if (obj.messagecallback) { obj.messagecallback(null, obj.instance); obj.messagecallback = null; } if (obj.messagecallback2) { obj.messagecallback2(null, obj.instance); obj.messagecallback2 = null; } return this; } var stream; if (attachment.storage) { FILESTORAGE(attachment.storage).readbase64(attachment.filename, function(err, meta) { if (err) { F.error(err, 'Mail.filestorage()', attachment.filename); Mailer.$writeattachment(obj); } else { if (!attachment.name) { attachment.name = meta.name; attachment.type = meta.type; attachment.extension = meta.ext; } writeattachemnt_stream(attachment, obj, meta.stream); } }); } else { F.stats.performance.open++; stream = F.Fs.createReadStream(attachment.filename, ATTACHMENT); writeattachemnt_stream(attachment, obj, stream); } return this; }; function writeattachemnt_stream(attachment, obj, stream) { var name = attachment.name; var isCalendar = attachment.extension === 'ics'; var message = []; message.push('--' + obj.boundary); if (!isCalendar) { if (attachment.contentid) { message.push('Content-Disposition: inline; filename="' + name + '"'); message.push('Content-ID: <' + attachment.contentid + '>'); } else message.push('Content-Disposition: attachment; filename="' + name + '"'); } message.push('Content-Type: ' + attachment.type + ';' + (isCalendar ? ' charset="utf-8"; method=REQUEST' : '')); message.push('Content-Transfer-Encoding: base64'); message.push(CRLF); Mailer.$writeline(obj, message.join(CRLF)); stream.$mailerdata = obj; stream.on('data', writeattachmentbytes); F.cleanup(stream, function() { Mailer.$writeline(obj, CRLF); Mailer.$writeattachment(obj); }); } function writeattachmentbytes(chunk) { var length = chunk.length; var count = 0; var beg = 0; while (count < length) { count += 68; if (count > length) count = length; Mailer.$writeline(this.$mailerdata, chunk.slice(beg, count).toString('base64')); beg = count; } } Mailer.try = function(options, callback) { var self = this; if (callback) self.send(options, undefined, callback); else return new Promise((resolve, reject) => self.send(options, undefined, err => err ? reject(err) : resolve())); }; Mailer.send2 = function(messages, callback) { return this.send(F.config.smtp, messages, callback); }; Mailer.send = function(opt, messages, callback) { var cache = opt.keepalive; var cached = cache ? F.temporary.smtp[opt.server] : null; if (cached) { if (messages instanceof Array) { var count = messages.length; F.stats.performance.mail += count; for (var i = 0; i < count; i++) cached.messages.push(messages[i]); } else if (messages) { F.stats.performance.mail++; cached.messages.push(messages); } cached.trytosend(); return; } var self = this; var id = F.TUtils.guid(); self.connections[id] = {}; var obj = self.connections[id]; obj.id = id; obj.buffer = []; obj.try = messages === undefined; obj.messages = obj.try ? F.EMPTYARRAY : messages instanceof Array ? messages : [messages]; F.stats.performance.mail += obj.messages.length; obj.callback = callback; obj.closed = false; obj.message = null; obj.attachments = null; obj.count = 0; obj.socket; obj.tls = false; obj.date = new Date(); if (opt.secure && !opt.port) opt.port = 465; if (!opt.server) { var err = new Error('No SMTP server configuration.'); callback && callback(err); F.error(err, 'mail_smtp'); return self; } if (opt.secure) { let internal = F.TUtils.copy(opt); internal.host = opt.server; obj.socket = F.Tls.connect(internal, () => Mailer.$send(obj, opt)); } else obj.socket = F.Net.createConnection(opt.port, opt.server); if (cache) { obj.trytosend = function() { if (!obj.sending && obj.messages && obj.messages.length) { obj.sending = true; obj.buffer = []; Mailer.$writemessage(obj, obj.buffer); Mailer.$writeline(obj, obj.buffer.shift()); } }; obj.TS = NOW.add(cache === true ? '10 minutes' : typeof(cache) === 'number' ? (cache + ' minutes') : cache); F.temporary.smtp[opt.server] = obj; } obj.cached = cache; obj.smtp = opt; obj.socket.$host = opt.server; obj.host = opt.server.substring(opt.server.lastIndexOf('.', opt.server.lastIndexOf('.') - 1) + 1); obj.socket.on('error', function(err) { Mailer.destroy(obj); var is = obj.callback ? true : false; obj.callback && obj.callback(err); obj.callback = null; if (obj.try || err.stack.indexOf('ECONNRESET') !== -1) return; if (!obj.try && !is) F.error(err, 'mail_smtp', opt.server); if (obj === F.temporary.smtp[opt.server]) delete F.temporary.smtp[opt.server]; Mailer.$events.error && Mailer.emit('error', err, obj); }); obj.socket.on('clientError', function(err) { Mailer.destroy(obj); if (!obj.try && !obj.callback) F.error(err, 'mail_smtp', opt.server); obj.callback && obj.callback(err); obj.callback = null; if (obj === F.temporary.smtp[opt.server]) delete F.temporary.smtp[opt.server]; if (Mailer.$events.error && !obj.try) Mailer.emit('error', err, obj); }); if (!cache) { obj.socket.setTimeout(opt.timeout || 60000, function() { var err = F.TUtils.httpstatus(408); Mailer.destroy(obj); if (!obj.try && !obj.callback) F.error(err, 'mail_smtp', opt.server); obj.callback && obj.callback(err); obj.callback = null; if (obj === F.temporary.smtp[opt.server]) delete F.temporary.smtp[opt.server]; if (Mailer.$events.error && !obj.try) Mailer.emit('error', err, obj); }); } obj.sending = true; obj.socket.on('connect', () => !opt.secure && Mailer.$send(obj, opt)); }; Mailer.$writemessage = function(obj, buffer) { var self = this; var msg = obj.messages.shift(); var message = []; F.stats.other.mail++; F.$events.$mail && F.emit('$mail', msg); var dt = obj.date.getTime(); obj.boundary = '--total5' + dt; obj.files = msg.files; obj.count++; message.push('MIME-Version: 1.0'); buffer.push('MAIL FROM: <' + msg.email_from + '>'); if (!Mailer.domain) Mailer.domain = msg.email_from.substring(msg.email_from.lastIndexOf('@')); message.push('Message-ID: <total5X' + dt.toString(36) + 'X' + (INDEXATTACHMENT++) + 'X' + (INDEXATTACHMENT) + Mailer.domain + '>'); self.$priority && message.push('X-Priority: ' + self.$priority); self.$confidential && message.push('Sensitivity: Company-Confidential'); message.push('From: ' + (msg.email_from_name ? (unicode_encode(msg.email_from_name) + ' <' + msg.email_from + '>') : msg.email_from)); if (msg.headers) { for (let key in msg.headers) message.push(key + ': ' + msg.headers[key]); } var builder = ''; var mail; if (msg.email_to.length) { for (let item of msg.email_to) { mail = '<' + item + '>'; buffer.push('RCPT TO: ' + mail); builder += (builder ? ', ' : '') + mail; } message.push('To: ' + builder); builder = ''; } if (msg.email_cc) { for (let item of msg.email_cc) { mail = '<' + item + '>'; buffer.push('RCPT TO: ' + mail); builder += (builder ? ', ' : '') + mail; } message.push('Cc: ' + builder); builder = ''; } if (msg.email_bcc) { for (let item of msg.email_bcc) buffer.push('RCPT TO: <' + item + '>'); } buffer.push('DATA'); buffer.push(''); message.push('Date: ' + obj.date.toUTCString()); message.push('Subject: ' + unicode_encode(msg.subject)); if (msg.$unsubscribe) { message.push('List-Unsubscribe: ' + msg.$unsubscribe); message.push('List-Unsubscribe-Post: List-Unsubscribe=One-Click'); } if (msg.email_reply) { for (let item of msg.email_reply) builder += (builder !== '' ? ', ' : '') + '<' + item + '>'; message.push('Reply-To: ' + builder); builder = ''; } message.push('Content-Type: multipart/mixed; boundary="' + obj.boundary + '"'); message.push(''); message.push('--' + obj.boundary); message.push('Content-Type: text/' + msg.type + '; charset="utf-8"'); message.push('Content-Transfer-Encoding: base64'); message.push(''); message.push(prepareBASE64(Buffer.from(msg.body.replace(REG_WINLINE, '\n').replace(REG_NEWLINE, CRLF)).toString('base64'))); // if (msg.type === 'html' && msg.$preview) { // message.push('--' + obj.boundary); // message.push('Content-Type: text/plain; charset="utf-8"; format="fixed"'); // message.push('Content-Transfer-Encoding: base64'); // message.push(''); // message.push(prepareBASE64(Buffer.from(msg.$preview.replace(REG_WINLINE, '\n').replace(REG_NEWLINE, CRLF)).toString('base64'))); // } obj.message = message.join(CRLF); obj.messagecallback = msg.$callback; obj.messagecallback2 = msg.$callback2; obj.instance = msg; message = null; return self; }; Mailer.$writeline = function(obj) { if (obj.closed) return false; var socket = obj.socket2 ? obj.socket2 : obj.socket; for (var i = 1; i < arguments.length; i++) { var line = arguments[i]; if (line) { Mailer.debug && console.log('SEND', line); socket.write(line + CRLF); } } return true; }; Mailer.$send = function(obj, options, autosend) { var self = this; var isAuthorized = false; var isAuthorization = false; var command = ''; var auth = []; var socket = obj.socket2 ? obj.socket2 : obj.socket; var host = obj.host; var line = null; var isAttach = !options.tls || (obj.tls && options.tls); isAttach && Mailer.$events.send && Mailer.emit('send', obj); socket.setEncoding('utf8'); socket.on('end', function() { Mailer.destroy(obj); obj.callback && obj.callback(); obj.callback = null; if (obj.cached) delete F.temporary.smtp[obj.server]; line = null; }); socket.on('data', function(data) { if (obj.closed) return; while (true) { var index = data.indexOf(CRLF_BUFFER); if (index === -1) { if (line) { CONCAT[0] = line; CONCAT[1] = data; line = Buffer.concat(CONCAT); } else line = data; break; } var tmp = data.slice(0, index).toString('utf8'); data = data.slice(index + CRLF_BUFFER.length); tmp && socket && socket.emit('line', tmp); } }); socket.on('line', function(line) { line = line.toUpperCase(); Mailer.debug && console.log('<---', line); var code = +line.match(REG_STATE)[0]; if (code === 250 && !isAuthorization) { if (REG_AUTH.test(line) && (options.user && (options.password || options.token))) { isAuthorization = true; if (options.token && line.indexOf('XOAUTH2') !== -1) { auth.push('AUTH XOAUTH2'); auth.push(Buffer.from('user=' + options.user + '\x01auth=Bearer ' + options.token + '\x01\x01').toString('base64')); } else if (line.lastIndexOf('XOAUTH') === -1) { auth.push('AUTH LOGIN'); auth.push(Buffer.from(options.user).toString('base64')); auth.push(Buffer.from(options.password).toString('base64')); } else auth.push('AUTH PLAIN ' + Buffer.from('\0'+ options.user + '\0' + options.password).toString('base64')); } } // help if (line.substring(3, 4) === '-') return; if (!isAuthorized && isAuthorization) { isAuthorized = true; code = 334; } switch (code) { case 220: if (obj.isTLS || REG_TLS.test(line)) { Mailer.tls(obj, options); } else { obj.secured = REG_ESMTP.test(line); command = options.heloid ? options.heloid : (obj.isTLS || (options.user && options.password) || obj.secured ? 'EHLO' : 'HELO'); Mailer.$writeline(obj, command + ' ' + host); } return; case 250: // OPERATION case 251: // FORWARD case 235: // VERIFY case 999: // Total.js again if (obj.secured && !obj.isTLS && !obj.logged && obj.smtp.user && obj.smtp.password) { // maybe TLS obj.isTLS = true; Mailer.$writeline(obj, 'STARTTLS'); return; } Mailer.$writeline(obj, obj.buffer.shift()); if (obj.buffer.length) return; // NEW MESSAGE if (obj.messages.length) { obj.buffer = []; Mailer.$writemessage(obj, obj.buffer); Mailer.$writeline(obj, obj.buffer.shift()); } else { obj.sending = false; // end if (obj.cached) obj.trytosend(); else Mailer.$writeline(obj, 'QUIT'); } return; case 221: // BYE if (!obj.cached) Mailer.destroy(obj); obj.callback && obj.callback(null, obj.try ? true : obj.count); obj.callback = null; return; case 334: // LOGIN if (!self.tls && !obj.isTLS && options.tls) { obj.isTLS = true; Mailer.$writeline(obj, 'STARTTLS'); return; } var value = auth.shift(); if (value) { obj.logged = true; Mailer.$writeline(obj, value); } else { var err = new Error('Forbidden.'); Mailer.destroy(obj); obj.callback && obj.callback(err); obj.callback = null; Mailer.$events.error && !obj.try && Mailer.emit('error', err, obj); } return; case 354: Mailer.$writeline(obj, obj.message); Mailer.$writeattachment(obj); obj.message = null; return; default: if (code < 400) return; if (!obj.isTLS && code === 530 && REG_STARTTLS.test(line)) { obj.isTLS = true; Mailer.$writeline(obj, 'STARTTLS'); return; } var err = line; Mailer.$events.error && !obj.try && Mailer.emit('error', err, obj); if (obj.messagecallback) { obj.messagecallback(err, obj.instance); obj.messagecallback = null; } if (obj.messagecallback2) { obj.messagecallback2(err, obj.instance); obj.messagecallback2 = null; } if (obj.messages.length) { F.error(err, 'SMTP error'); // a problem obj.buffer = []; obj.count--; socket.emit('line', '999 TRY NEXT MESSAGE'); } else { Mailer.destroy(obj); obj.callback && obj.callback(err); obj.callback = null; } return; } }); autosend && self.$writeline(obj, 'EHLO ' + host); }; Mailer.restart = function() { var self = this; self.off(); self.debug = false; INDEXATTACHMENT = 0; }; // Split Base64 to lines with 68 characters function prepareBASE64(value) { var index = 0; var output = ''; var length = value.length; while (index < length) { var max = index + 68; if (max > length) max = length; output += value.substring(index, max) + CRLF; index = max; } return output; } function unicode_encode(val) { return val ? '=?utf-8?B?' + Buffer.from(val.toString()).toString('base64') + '?=' : ''; } // ====================================================== // EXPORTS // ====================================================== exports.Mailer = Mailer; exports.Message = Message; exports.refresh = function() { for (let key in F.temporary.smtp) { let conn = F.temporary.smtp[key]; if (conn.TS < NOW && (!conn.messages || !conn.messages.length)) Mailer.destroy(F.temporary.smtp[key]); } };