diff --git a/smtp/client.ts b/smtp/client.ts index 547d028..5a0d99e 100644 --- a/smtp/client.ts +++ b/smtp/client.ts @@ -18,51 +18,16 @@ export interface MessageStack { export class Client { public smtp: SMTP; public queue: MessageStack[] = []; - public timer: any; - public sending: boolean; - public ready: boolean; + public timer: NodeJS.Timer | null = null; + public sending = false; + public ready = false; /** - * @typedef {Object} MessageStack - * - * @typedef {Object} SMTPSocketOptions - * @property {string} key - * @property {string} ca - * @property {string} cert - * - * @typedef {Object} SMTPOptions - * @property {number} [timeout] - * @property {string} [user] - * @property {string} [password] - * @property {string} [domain] - * @property {string} [host] - * @property {number} [port] - * @property {boolean|SMTPSocketOptions} [ssl] - * @property {boolean|SMTPSocketOptions} [tls] - * @property {string[]} [authentication] - * @property {function(...any): void} [logger] - * - * @constructor - * @param {SMTPOptions} server smtp options + * @param {*} server smtp options */ constructor(server: Partial) { this.smtp = new SMTP(server); //this.smtp.debug(1); - - /** - * @type {NodeJS.Timer} - */ - this.timer = null; - - /** - * @type {boolean} - */ - this.sending = false; - - /** - * @type {boolean} - */ - this.ready = false; } send(msg: Message, callback: (err: Error, msg: Message) => void): void { @@ -107,7 +72,7 @@ export class Client { this.queue.push(stack); this._poll(); } else { - callback(new Error(why), /** @type {MessageStack} */ msg); + callback(new Error(why), msg); } }); } @@ -117,13 +82,15 @@ export class Client { * @returns {void} */ _poll(): void { - clearTimeout(this.timer); + if (this.timer != null) { + clearTimeout(this.timer); + } if (this.queue.length > 0) { - if (this.smtp.state == SMTPState.NOTCONNECTED) { + if (this.smtp.state() == SMTPState.NOTCONNECTED) { this._connect(this.queue[0]); } else if ( - this.smtp.state == SMTPState.CONNECTED && + this.smtp.state() == SMTPState.CONNECTED && !this.sending && this.ready ) { @@ -132,7 +99,7 @@ export class Client { } // wait around 1 seconds in case something does come in, // otherwise close out SMTP connection if still open - else if (this.smtp.state == SMTPState.CONNECTED) { + else if (this.smtp.state() == SMTPState.CONNECTED) { this.timer = setTimeout(() => this.smtp.quit(), 1000); } } @@ -162,10 +129,10 @@ export class Client { } }; - if (!this.smtp.isAuthorized) { - this.smtp.login(begin); - } else { + if (this.smtp.authorized()) { this.smtp.ehlo_or_helo_if_needed(begin); + } else { + this.smtp.login(begin); } } else { stack.callback(err, stack.message); @@ -189,16 +156,16 @@ export class Client { return !!( msg.from && (msg.to || msg.cc || msg.bcc) && - (msg.text != null || this._containsInlinedHtml(msg.attachment)) + (msg.text !== undefined || this._containsInlinedHtml(msg.attachment)) ); } /** * @private * @param {*} attachment attachment - * @returns {boolean} does contain + * @returns {*} whether the attachment contains inlined html */ - _containsInlinedHtml(attachment: any): boolean { + _containsInlinedHtml(attachment: any) { if (Array.isArray(attachment)) { return attachment.some((att) => { return this._isAttachmentInlinedHtml(att); @@ -211,9 +178,9 @@ export class Client { /** * @private * @param {*} attachment attachment - * @returns {boolean} is inlined + * @returns {boolean} whether the attachment is inlined html */ - _isAttachmentInlinedHtml(attachment: any): boolean { + _isAttachmentInlinedHtml(attachment: any) { return ( attachment && (attachment.data || attachment.path) && @@ -267,9 +234,12 @@ export class Client { throw new TypeError('stack.to must be array'); } - const { address: to } = stack.to.shift() ?? {}; + const to = stack.to.shift()?.address; this.smtp.rcpt( - this._sendsmtp(stack, stack.to.length ? this._sendrcpt : this._senddata), + this._sendsmtp( + stack, + stack.to.length > 0 ? this._sendrcpt : this._senddata + ), `<${to}>` ); } diff --git a/smtp/message.ts b/smtp/message.ts index 3293578..60062f5 100644 --- a/smtp/message.ts +++ b/smtp/message.ts @@ -10,22 +10,22 @@ import { getRFC2822Date } from './date'; type Indexed = import('@ledge/types').Indexed; -const CRLF = '\r\n'; +const CRLF = '\r\n' as const; /** * MIME standard wants 76 char chunks when sending out. */ -export const MIMECHUNK: 76 = 76; +export const MIMECHUNK = 76 as const; /** * meets both base64 and mime divisibility */ -export const MIME64CHUNK: 456 = (MIMECHUNK * 6) as 456; +export const MIME64CHUNK = (MIMECHUNK * 6) as 456; /** * size of the message stream buffer */ -export const BUFFERSIZE: 12768 = (MIMECHUNK * 24 * 7) as 12768; +export const BUFFERSIZE = (MIMECHUNK * 24 * 7) as 12768; export interface MessageAttachmentHeaders extends Indexed { 'content-type'?: string; @@ -34,7 +34,7 @@ export interface MessageAttachmentHeaders extends Indexed { } export interface AlternateMessageAttachment extends Indexed { - headers: MessageAttachmentHeaders; + headers?: MessageAttachmentHeaders; inline: boolean; alternative?: MessageAttachment; related?: MessageAttachment[]; @@ -97,35 +97,33 @@ function convertDashDelimitedTextToSnakeCase(text: string) { export class Message { attachments: any[] = []; alternative: AlternateMessageAttachment | null = null; - header: Partial; - content: string; + header: Partial = { + 'message-id': `<${new Date().getTime()}.${counter++}.${ + process.pid + }@${hostname()}>`, + date: getRFC2822Date(), + }; + content = 'text/plain; charset=utf-8'; text: any; constructor(headers: Partial) { - this.header = { - 'message-id': `<${new Date().getTime()}.${counter++}.${ - process.pid - }@${hostname()}>`, - date: getRFC2822Date(), - }; - - this.content = 'text/plain; charset=utf-8'; for (const header in headers) { // allow user to override default content-type to override charset or send a single non-text message if (/^content-type$/i.test(header)) { this.content = headers[header]; } else if (header === 'text') { this.text = headers[header]; - } else if (header === 'attachment') { + } else if ( + header === 'attachment' && + typeof headers[header] === 'object' + ) { const attachment = headers[header]; - if (attachment != null) { - if (Array.isArray(attachment)) { - for (let i = 0; i < attachment.length; i++) { - this.attach(attachment[i]); - } - } else { - this.attach(attachment); + if (Array.isArray(attachment)) { + for (let i = 0; i < attachment.length; i++) { + this.attach(attachment[i]); } + } else if (attachment != null) { + this.attach(attachment); } } else if (header === 'subject') { this.header.subject = mimeWordEncode(headers.subject); @@ -166,11 +164,10 @@ export class Message { * @param {string} [charset='utf-8'] the charset to encode as * @returns {Message} the current Message instance */ - attach_alternative(html: string, charset = 'utf-8'): Message { + attach_alternative(html: string, charset: string): Message { this.alternative = { - headers: {}, data: html, - charset, + charset: charset || 'utf-8', type: 'text/html', inline: true, }; @@ -182,7 +179,7 @@ export class Message { * @param {function(boolean, string): void} callback This callback is displayed as part of the Requester class. * @returns {void} */ - valid(callback: (arg0: boolean, arg1?: string) => void): void { + valid(callback: (arg0: boolean, arg1?: string) => void) { if (!this.header.from) { callback(false, 'message does not have a valid sender'); } @@ -213,10 +210,9 @@ export class Message { } /** - * returns a stream of the current message - * @returns {MessageStream} a stream of the current message + * @returns {*} a stream of the current message */ - stream(): MessageStream { + stream() { return new MessageStream(this); } @@ -224,7 +220,7 @@ export class Message { * @param {function(Error, string): void} callback the function to call with the error and buffer * @returns {void} */ - read(callback: (arg0: Error, arg1: string) => void): void { + read(callback: (err: Error, buffer: string) => void) { let buffer = ''; const str = this.stream(); str.on('data', (data) => (buffer += data)); @@ -234,46 +230,18 @@ export class Message { } class MessageStream extends Stream { - message: Message; - readable: boolean; - paused: boolean; - buffer: Buffer | null; - bufferIndex: number; + readable = true; + paused = false; + buffer: Buffer | null = Buffer.alloc(MIMECHUNK * 24 * 7); + bufferIndex = 0; + /** - * @param {Message} message the message to stream + * @param {*} message the message to stream */ - constructor(message: Message) { + constructor(private message: Message) { super(); - /** - * @type {Message} - */ - this.message = message; - - /** - * @type {boolean} - */ - this.readable = true; - - /** - * @type {boolean} - */ - this.paused = false; - - /** - * @type {Buffer} - */ - this.buffer = Buffer.alloc(MIMECHUNK * 24 * 7); - - /** - * @type {number} - */ - this.bufferIndex = 0; - - /** - * @returns {void} - */ - const output_mixed = (): void => { + const output_mixed = () => { const boundary = generate_boundary(); output( `Content-Type: multipart/mixed; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}` @@ -303,7 +271,7 @@ class MessageStream extends Stream { list: MessageAttachment[], index: number, callback: () => void - ): void => { + ) => { if (index < list.length) { output(`--${boundary}${CRLF}`); if (list[index].related) { @@ -327,7 +295,7 @@ class MessageStream extends Stream { */ const output_attachment_headers = ( attachment: MessageAttachment | AlternateMessageAttachment - ): void => { + ) => { let data: string[] = []; const headers: Partial = { 'content-type': @@ -341,8 +309,10 @@ class MessageStream extends Stream { }; // allow sender to override default headers - for (const header in attachment.headers || {}) { - headers[header.toLowerCase()] = attachment.headers[header]; + if (attachment.headers != null) { + for (const header in attachment.headers) { + headers[header.toLowerCase()] = attachment.headers[header]; + } } for (const header in headers) { @@ -360,7 +330,7 @@ class MessageStream extends Stream { const output_attachment = ( attachment: MessageAttachment | AlternateMessageAttachment, callback: () => void - ): void => { + ) => { const build = attachment.path ? output_file : attachment.stream @@ -378,7 +348,7 @@ class MessageStream extends Stream { const output_data = ( attachment: MessageAttachment | AlternateMessageAttachment, callback: () => void - ): void => { + ) => { output_base64( attachment.encoded ? attachment.data @@ -390,7 +360,7 @@ class MessageStream extends Stream { const output_file = ( attachment: MessageAttachment | AlternateMessageAttachment, next: (err: NodeJS.ErrnoException) => void - ): void => { + ) => { const chunk = MIME64CHUNK * 16; const buffer = Buffer.alloc(chunk); const closed = (fd: number) => fs.closeSync(fd); @@ -400,7 +370,7 @@ class MessageStream extends Stream { * @param {number} fd the file descriptor * @returns {void} */ - const opened = (err: Error, fd: number): void => { + const opened = (err: Error, fd: number) => { if (!err) { const read = (err: Error, bytes: number) => { if (!err && this.readable) { @@ -452,13 +422,13 @@ class MessageStream extends Stream { const output_stream = ( attachment: MessageAttachment | AlternateMessageAttachment, callback: () => void - ): void => { + ) => { if (attachment.stream.readable) { let previous = Buffer.alloc(0); attachment.stream.resume(); - (attachment as MessageAttachment).on('end', () => { + (attachment as MessageAttachment).stream.on('end', () => { output_base64(previous.toString('base64'), callback); this.removeListener('pause', attachment.stream.pause); this.removeListener('resume', attachment.stream.resume); @@ -497,7 +467,7 @@ class MessageStream extends Stream { * @param {function(): void} [callback] the function to call after output is finished * @returns {void} */ - const output_base64 = (data: string, callback?: () => void): void => { + const output_base64 = (data: string, callback?: () => void) => { const loops = Math.ceil(data.length / MIMECHUNK); let loop = 0; while (loop < loops) { @@ -513,7 +483,7 @@ class MessageStream extends Stream { * @param {Message} message the message to output * @returns {void} */ - const output_text = (message: Message): void => { + const output_text = (message: Message) => { let data: string[] = []; data = data.concat([ @@ -537,7 +507,7 @@ class MessageStream extends Stream { const output_alternative = ( message: Message & { alternative: AlternateMessageAttachment }, callback: () => void - ): void => { + ) => { const boundary = generate_boundary(); output( `Content-Type: multipart/alternative; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}` @@ -548,7 +518,7 @@ class MessageStream extends Stream { /** * @returns {void} */ - const finish = (): void => { + const finish = () => { output([CRLF, '--', boundary, '--', CRLF, CRLF].join('')); callback(); }; @@ -568,7 +538,7 @@ class MessageStream extends Stream { const output_related = ( message: AlternateMessageAttachment, callback: () => void - ): void => { + ) => { const boundary = generate_boundary(); output( `Content-Type: multipart/related; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}` @@ -584,7 +554,7 @@ class MessageStream extends Stream { /** * @returns {void} */ - const output_header_data = (): void => { + const output_header_data = () => { if (this.message.attachments.length || this.message.alternative) { output(`MIME-Version: 1.0${CRLF}`); output_mixed(); @@ -598,14 +568,15 @@ class MessageStream extends Stream { /** * @returns {void} */ - const output_header = (): void => { + const output_header = () => { let data: string[] = []; for (const header in this.message.header) { // do not output BCC in the headers (regex) nor custom Object.prototype functions... if ( !/bcc/i.test(header) && - Object.prototype.hasOwnProperty.call(this.message.header, header) + // eslint-disable-next-line no-prototype-builtins + this.message.header.hasOwnProperty(header) ) { data = data.concat([ convertDashDelimitedTextToSnakeCase(header), @@ -626,11 +597,7 @@ class MessageStream extends Stream { * @param {any[]} [args] array of arguments to pass to the callback * @returns {void} */ - const output = ( - data: string, - callback?: (...args: any[]) => void, - args: any[] = [] - ) => { + const output = (data: string) => { // can we buffer the data? if (this.buffer != null) { const bytes = Buffer.byteLength(data); @@ -638,9 +605,6 @@ class MessageStream extends Stream { if (bytes + this.bufferIndex < this.buffer.length) { this.buffer.write(data, this.bufferIndex); this.bufferIndex += bytes; - if (callback) { - callback.apply(null, args); - } } // we can't buffer the data, so ship it out! else if (bytes > this.buffer.length) { @@ -673,24 +637,15 @@ class MessageStream extends Stream { ); this.buffer.write(data, 0); this.bufferIndex = bytes; - // we could get paused after emitting data... - - if (typeof callback === 'function') { - if (this.paused) { - this.once('resume', () => callback.apply(null, args)); - } else { - callback.apply(null, args); - } - } - } // we can't empty out the buffer, so let's wait till we resume before adding to it - else { - this.once('resume', () => output(data, callback, args)); + } else { + // we can't empty out the buffer, so let's wait till we resume before adding to it + this.once('resume', () => output(data)); } } } }; - const close = (err?: any): void => { + const close = (err?: any) => { if (err) { this.emit('error', err); } else { @@ -718,7 +673,7 @@ class MessageStream extends Stream { * pause the stream * @returns {void} */ - pause(): void { + pause() { this.paused = true; this.emit('pause'); } @@ -727,7 +682,7 @@ class MessageStream extends Stream { * resume the stream * @returns {void} */ - resume(): void { + resume() { this.paused = false; this.emit('resume'); } @@ -736,7 +691,7 @@ class MessageStream extends Stream { * destroy the stream * @returns {void} */ - destroy(): void { + destroy() { this.emit( 'destroy', this.bufferIndex > 0 ? { message: 'message stream destroyed' } : null @@ -747,7 +702,7 @@ class MessageStream extends Stream { * destroy the stream at first opportunity * @returns {void} */ - destroySoon(): void { + destroySoon() { this.emit('destroy'); } } diff --git a/smtp/response.ts b/smtp/response.ts index 4589e41..6aa82b9 100644 --- a/smtp/response.ts +++ b/smtp/response.ts @@ -12,8 +12,8 @@ export class SMTPResponse { ) { const watch = (data: Parameters[0]) => this.watch(data); - const end = () => this.end(); - const close = () => this.close(); + const end = (err: Error) => this.end(err); + const close = (err: Error) => this.close(err); const error = (data: Parameters[0]) => this.error(data); const timedout = (data: Parameters[0]) => @@ -43,12 +43,13 @@ export class SMTPResponse { // parse buffer for response codes const line = this.buffer.replace('\r', ''); if ( - !line - .trim() - .split(/\n/) - .pop() - ?.match(/^(\d{3})\s/) ?? - false + !( + line + .trim() + .split(/\n/) + .pop() + ?.match(/^(\d{3})\s/) ?? false + ) ) { return; } @@ -75,13 +76,6 @@ export class SMTPResponse { ); } - protected watch(data: string | Buffer) { - if (data !== null) { - this.buffer += data.toString(); - this.notify(); - } - } - protected timedout(err: Error) { this.stream.end(); this.stream.emit( @@ -94,17 +88,32 @@ export class SMTPResponse { ); } - protected close() { + protected watch(data: string | Buffer) { + if (data !== null) { + this.buffer += data.toString(); + this.notify(); + } + } + + protected close(err: Error) { this.stream.emit( 'response', - makeSMTPError('connection has closed', SMTPErrorStates.CONNECTIONCLOSED) + makeSMTPError( + 'connection has closed', + SMTPErrorStates.CONNECTIONCLOSED, + err + ) ); } - protected end() { + protected end(err: Error) { this.stream.emit( 'response', - makeSMTPError('connection has ended', SMTPErrorStates.CONNECTIONENDED) + makeSMTPError( + 'connection has ended', + SMTPErrorStates.CONNECTIONENDED, + err + ) ); } } diff --git a/smtp/smtp.ts b/smtp/smtp.ts index 6494413..844997c 100644 --- a/smtp/smtp.ts +++ b/smtp/smtp.ts @@ -8,10 +8,6 @@ import { SMTPResponse } from './response'; import { makeSMTPError, SMTPErrorStates } from './error'; /* eslint-disable no-unused-vars */ -/** - * @readonly - * @enum - */ export enum AUTH_METHODS { PLAIN = 'PLAIN', CRAM_MD5 = 'CRAM-MD5', @@ -19,10 +15,6 @@ export enum AUTH_METHODS { XOAUTH2 = 'XOAUTH2', } -/** - * @readonly - * @enum - */ export enum SMTPState { NOTCONNECTED = 0, CONNECTING = 1, @@ -30,39 +22,11 @@ export enum SMTPState { } /* eslint-enable no-unused-vars */ -/** - * @readonly - * @type {5000} - */ -export const DEFAULT_TIMEOUT: 5000 = 5000; - -/** - * @readonly - * @type {25} - */ -const SMTP_PORT: 25 = 25; - -/** - * @readonly - * @type {465} - */ -const SMTP_SSL_PORT: 465 = 465; - -/** - * @readonly - * @type {587} - */ -const SMTP_TLS_PORT: 587 = 587; - -/** - * @readonly - * @type {'\r\n'} - */ -const CRLF: '\r\n' = '\r\n'; - -/** - * @type {0 | 1} - */ +export const DEFAULT_TIMEOUT = 5000 as const; +const SMTP_PORT = 25 as const; +const SMTP_SSL_PORT = 465 as const; +const SMTP_TLS_PORT = 587 as const; +const CRLF = '\r\n' as const; let DEBUG: 0 | 1 = 0; /** @@ -118,34 +82,28 @@ export interface ConnectOptions { } export class SMTP extends EventEmitter { - private _state: 0 | 1 | 2 = SMTPState.NOTCONNECTED; + private _state: SMTPState = SMTPState.NOTCONNECTED; private _isAuthorized = false; private _isSecure = false; private _user?: string = ''; private _password?: string = ''; - private _timeout: number = DEFAULT_TIMEOUT; + public timeout: number = DEFAULT_TIMEOUT; public set debug(level: 0 | 1) { DEBUG = level; } - public get state() { + public state() { return this._state; } - public get timeout() { - return this._timeout; - } + public user: () => string; + public password: () => string; - public get user() { - return this._user; - } - - public get password() { - return this._password; - } - - public get isAuthorized() { + /** + * @returns {boolean} whether or not the instance is authorized + */ + public authorized() { return this._isAuthorized; } @@ -177,9 +135,6 @@ export class SMTP extends EventEmitter { }: Partial = {}) { super(); - this._user = user; - this._password = password; - this.authentication = Array.isArray(authentication) ? authentication : [ @@ -190,7 +145,7 @@ export class SMTP extends EventEmitter { ]; if (typeof timeout === 'number') { - this._timeout = timeout; + this.timeout = timeout; } if (typeof domain === 'string') { @@ -230,6 +185,10 @@ export class SMTP extends EventEmitter { } this._isAuthorized = user && password ? false : true; + + // keep these strings hidden when quicky debugging/logging + this.user = () => user as string; + this.password = () => password as string; } /** @@ -351,7 +310,7 @@ export class SMTP extends EventEmitter { this.sock.connect(this.port, this.host, connectedErrBack); } - this.monitor = new SMTPResponse(this.sock, this._timeout, () => + this.monitor = new SMTPResponse(this.sock, this.timeout, () => this.close(true) ); this.sock.once('response', response); @@ -483,7 +442,7 @@ export class SMTP extends EventEmitter { this._isSecure = true; this.sock = secureSocket; - new SMTPResponse(this.sock, this._timeout, () => this.close(true)); + new SMTPResponse(this.sock, this.timeout, () => this.close(true)); caller(callback, msg.data); } }; @@ -689,8 +648,8 @@ export class SMTP extends EventEmitter { options: { method?: string; domain?: string } = {} ): void { const login = { - user: () => user || this.user || '', - password: () => password || this.password || '', + user: (user?.length ?? 0) > 0 ? () => user : this.user, + password: (password?.length ?? 0) > 0 ? () => password : this.password, method: options && options.method ? options.method.toUpperCase() : '', };