1
0
mirror of https://github.com/eleith/emailjs.git synced 2024-06-18 05:39:03 +00:00

Merge pull request #246 from eleith/ø

migrate to typescript & node.js-compatible es modules
This commit is contained in:
zackschuster 2020-05-25 22:43:05 -07:00 committed by GitHub
commit a9d6878625
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 24874 additions and 7726 deletions

View File

@ -1,17 +1,33 @@
{ {
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 8,
"ecmaFeatures": {
"modules": true
},
"sourceType": "module"
},
"env": { "env": {
"es6": true,
"mocha": true,
"node": true "node": true
}, },
"plugins": [ "plugins": [
"mocha" "@typescript-eslint"
], ],
"extends": [ "extends": [
"eslint:recommended", "eslint:recommended",
"plugin:prettier/recommended" "plugin:prettier/recommended",
"plugin:@typescript-eslint/recommended"
], ],
"rules": { "rules": {
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": [
"error",
{
"ignoreRestArgs": true
}
],
"curly": [ "curly": [
"error", "error",
"all" "all"
@ -30,11 +46,6 @@
"ignoreRestSiblings": true "ignoreRestSiblings": true
} }
], ],
"valid-jsdoc": "error", "valid-jsdoc": "error"
"mocha/handle-done-callback": "error",
"mocha/no-exclusive-tests": "error",
"mocha/no-global-tests": "error",
"mocha/no-mocha-arrows": "error",
"mocha/no-skipped-tests": "error"
} }
} }

View File

@ -11,18 +11,21 @@ jobs:
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-latest, windows-latest, macos-latest]
steps: steps:
- name: checkout - name: checkout
uses: actions/checkout@v1 uses: actions/checkout@v1
with: with:
fetch-depth: 1 fetch-depth: 1
- name: node - name: node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
- name: install - name: install
run: yarn run: yarn
- name: test - name: test
run: yarn test run: yarn test
- name: test-cjs
run: yarn test-cjs

8
.gitignore vendored
View File

@ -1,7 +1,7 @@
/.vscode/ .vscode
/node_modules/ node_modules
/test/config.js
/npm-debug.log *.log
*.swp *.swp
*.swo *.swo
*~ *~

View File

@ -1,10 +0,0 @@
/node_modules/
/test/
/rollup/
/rollup.config.js
/email.esm.js
/.gitignore
/.npmignore
/.travis.yml
/*.log

5
.prettierrc.json Normal file
View File

@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "es5",
"useTabs": true
}

View File

@ -1,8 +0,0 @@
language: node_js
node_js:
- "6"
- "8"
- "10"
script:
- npm run test
- npm run rollup

193
Readme.md
View File

@ -1,4 +1,4 @@
# emailjs [![Build Status](https://secure.travis-ci.org/eleith/emailjs.png)](http://travis-ci.org/eleith/emailjs) [![Test Status](https://github.com/eleith/emailjs/workflows/.github/workflows/test.yml/badge.svg)](https://github.com/eleith/emailjs/actions?query=workflow%3A.github%2Fworkflows%2Ftest.yml) # emailjs [![Test Status](https://github.com/eleith/emailjs/workflows/.github/workflows/test.yml/badge.svg)](https://github.com/eleith/emailjs/actions?query=workflow%3A.github%2Fworkflows%2Ftest.yml)
send emails, html and attachments (files, streams and strings) from node.js to any smtp server send emails, html and attachments (files, streams and strings) from node.js to any smtp server
@ -21,119 +21,129 @@ send emails, html and attachments (files, streams and strings) from node.js to a
## EXAMPLE USAGE - text only emails ## EXAMPLE USAGE - text only emails
```javascript ```javascript
var email = require("./path/to/emailjs/email"); import { client as c } from 'emailjs';
var server = email.server.connect({
user: "username", const client = new c.Client({
password:"password", user: 'user',
host: "smtp.your-email.com", password: 'password',
ssl: true host: 'smtp.your-email.com',
ssl: true
}); });
// send the message and get a callback with an error or details of the message that was sent // send the message and get a callback with an error or details of the message that was sent
server.send({ client.send({
text: "i hope this works", text: 'i hope this works',
from: "you <username@your-email.com>", from: 'you <username@your-email.com>',
to: "someone <someone@your-email.com>, another <another@your-email.com>", to: 'someone <someone@your-email.com>, another <another@your-email.com>',
cc: "else <else@your-email.com>", cc: 'else <else@your-email.com>',
subject: "testing emailjs" subject: 'testing emailjs'
}, function(err, message) { console.log(err || message); }); }, (err, message) => {
console.log(err || message);
});
``` ```
## EXAMPLE USAGE - html emails and attachments ## EXAMPLE USAGE - html emails and attachments
```javascript ```javascript
var email = require("./path/to/emailjs/email"); import { client as c } from 'emailjs';
var server = email.server.connect({
user: "username", const client = new c.Client({
password:"password", user: 'user',
host: "smtp.your-email.com", password: 'password',
ssl: true host: 'smtp.your-email.com',
ssl: true
}); });
var message = { const message = {
text: "i hope this works", text: 'i hope this works',
from: "you <username@your-email.com>", from: 'you <username@your-email.com>',
to: "someone <someone@your-email.com>, another <another@your-email.com>", to: 'someone <someone@your-email.com>, another <another@your-email.com>',
cc: "else <else@your-email.com>", cc: 'else <else@your-email.com>',
subject: "testing emailjs", subject: 'testing emailjs',
attachment: attachment: [
[ { data: '<html>i <i>hope</i> this works!</html>', alternative: true },
{data:"<html>i <i>hope</i> this works!</html>", alternative:true}, { path: 'path/to/file.zip', type: 'application/zip', name: 'renamed.zip' }
{path:"path/to/file.zip", type:"application/zip", name:"renamed.zip"} ]
]
}; };
// send the message and get a callback with an error or details of the message that was sent // send the message and get a callback with an error or details of the message that was sent
server.send(message, function(err, message) { console.log(err || message); }); client.send(message, function(err, message) { console.log(err || message); });
// you can continue to send more messages with successive calls to 'server.send', // you can continue to send more messages with successive calls to 'client.send',
// they will be queued on the same smtp connection // they will be queued on the same smtp connection
// or you can create a new server connection with 'email.server.connect' // or instead of using the built-in client you can create an instance of 'smtp.SMTPConnection'
// to asynchronously send individual emails instead of a queue
``` ```
## EXAMPLE USAGE - sending through hotmail/outlook ## EXAMPLE USAGE - sending through outlook
```javascript ```javascript
var email = require("./path/to/emailjs/email"); import { client as c, message as m } from 'emailjs';
var server = email.server.connect({
user: "username", const client = new c.Client({
password:"password", user: 'user',
host: "smtp-mail.outlook.com", password: 'password',
tls: {ciphers: "SSLv3"} host: 'smtp-mail.outlook.com',
tls: {
ciphers: 'SSLv3'
}
}); });
var message = { const message = new m.Message({
text: "i hope this works", text: 'i hope this works',
from: "you <username@outlook.com>", from: 'you <username@outlook.com>',
to: "someone <someone@your-email.com>, another <another@your-email.com>", to: 'someone <someone@your-email.com>, another <another@your-email.com>',
cc: "else <else@your-email.com>", cc: 'else <else@your-email.com>',
subject: "testing emailjs", subject: 'testing emailjs',
attachment: attachment: [
[ { data: '<html>i <i>hope</i> this works!</html>', alternative: true },
{data:"<html>i <i>hope</i> this works!</html>", alternative:true}, { path: 'path/to/file.zip', type: 'application/zip', name: 'renamed.zip' }
{path:"path/to/file.zip", type:"application/zip", name:"renamed.zip"} ]
] });
};
// send the message and get a callback with an error or details of the message that was sent // send the message and get a callback with an error or details of the message that was sent
server.send(message, function(err, message) { console.log(err || message); }); client.send(message, (err, message) => {
console.log(err || message);
});
``` ```
## EXAMPLE USAGE - attaching and embedding an image ## EXAMPLE USAGE - attaching and embedding an image
```javascript ```javascript
var email = require("./path/to/emailjs/email"); import { client as c, message as m } from 'emailjs';
var server = email.server.connect({
user: "username", const client = new c.Client({
password:"password", user: 'user',
host: "smtp-mail.outlook.com", password: 'password',
tls: {ciphers: "SSLv3"} host: 'smtp-mail.outlook.com',
tls: {
ciphers: 'SSLv3'
}
}); });
var message = { const message = new m.Message({
text: "i hope this works", text: 'i hope this works',
from: "you <username@outlook.com>", from: 'you <username@outlook.com>',
to: "someone <someone@your-email.com>, another <another@your-email.com>", to: 'someone <someone@your-email.com>, another <another@your-email.com>',
cc: "else <else@your-email.com>", cc: 'else <else@your-email.com>',
subject: "testing emailjs", subject: 'testing emailjs',
attachment: attachment: [
[ { data: '<html>i <i>hope</i> this works! here is an image: <img src="cid:my-image" width="100" height ="50"> </html>' },
{data: "<html>i <i>hope</i> this works! here is an image: <img src='cid:my-image' width='100' height ='50'> </html>"}, { path: 'path/to/file.zip', type: 'application/zip', name: 'renamed.zip' },
{path:"path/to/file.zip", type:"application/zip", name:"renamed.zip"}, { path: 'path/to/image.jpg', type: 'image/jpg', headers: { 'Content-ID': '<my-image>' } }
{path:"path/to/image.jpg", type:"image/jpg", headers:{"Content-ID":"<my-image>"}} ]
] });
};
// send the message and get a callback with an error or details of the message that was sent // send the message and get a callback with an error or details of the message that was sent
server.send(message, function(err, message) { console.log(err || message); }); client.send(message, (err, message) => {
console.log(err || message);
});
``` ```
# API # API
## email.server.connect(options) ## new client.Client(options)
// options is an object with the following keys // options is an object with the following keys
options = options =
@ -150,7 +160,7 @@ server.send(message, function(err, message) { console.log(err || message); });
logger // override the built-in logger (useful for e.g. Azure Function Apps, where console.log doesn't work) logger // override the built-in logger (useful for e.g. Azure Function Apps, where console.log doesn't work)
} }
## email.server.send(message, callback) ## client.Client#send(message, callback)
// message can be a smtp.Message (as returned by email.message.create) // message can be a smtp.Message (as returned by email.message.create)
// or an object identical to the first argument accepted by email.message.create // or an object identical to the first argument accepted by email.message.create
@ -158,7 +168,7 @@ server.send(message, function(err, message) { console.log(err || message); });
// callback will be executed with (err, message) // callback will be executed with (err, message)
// either when message is sent or an error has occurred // either when message is sent or an error has occurred
## message ## new message.Message(headers)
// headers is an object ('from' and 'to' are required) // headers is an object ('from' and 'to' are required)
// returns a Message object // returns a Message object
@ -177,11 +187,7 @@ server.send(message, function(err, message) { console.log(err || message); });
attachment // one attachment or array of attachments attachment // one attachment or array of attachments
} }
## email.SMTP.authentication ## message.Message#attach
associative array of currently supported SMTP authentication mechanisms
## attachment
// can be called multiple times, each adding a new attachment // can be called multiple times, each adding a new attachment
// options is an object with the following possible keys: // options is an object with the following possible keys:
@ -207,6 +213,27 @@ associative array of currently supported SMTP authentication mechanisms
related // an array of attachments that you want to be related to the parent attachment related // an array of attachments that you want to be related to the parent attachment
} }
## new smtp.SMTPConnection(options)
// options is an object with the following keys
options =
{
user // username for logging into smtp
password // password for logging into smtp
host // smtp host
port // smtp port (if null a standard port number will be used)
ssl // boolean or object {key, ca, cert} (if true or object, ssl connection will be made)
tls // boolean or object (if true or object, starttls will be initiated)
timeout // max number of milliseconds to wait for smtp responses (defaults to 5000)
domain // domain to greet smtp with (defaults to os.hostname)
authentication // array of preferred authentication methods ('PLAIN', 'LOGIN', 'CRAM-MD5', 'XOAUTH2')
logger // override the built-in logger (useful for e.g. Azure Function Apps, where console.log doesn't work)
}
## smtp.SMTPConnection#authentication
associative array of currently supported SMTP authentication mechanisms
## Authors ## Authors
eleith eleith

8
ava.config.js Normal file
View File

@ -0,0 +1,8 @@
export default {
files: ['test/*.ts'],
extensions: ['ts'],
require: ['./email.test.ts'],
environmentVariables: {
NODE_TLS_REJECT_UNAUTHORIZED: '0',
},
};

View File

@ -1,7 +0,0 @@
import * as server from './smtp/client';
import * as message from './smtp/message';
import * as date from './smtp/date';
import * as SMTP from './smtp/smtp';
import * as error from './smtp/error';
export { server, message, date, SMTP, error };

View File

@ -1,5 +0,0 @@
exports.server = require('./smtp/client');
exports.message = require('./smtp/message');
exports.date = require('./smtp/date');
exports.SMTP = require('./smtp/smtp');
exports.error = require('./smtp/error');

7
email.test.ts Normal file
View File

@ -0,0 +1,7 @@
require('ts-node/register');
if (process.title === 'cjs') {
require('./rollup/email.cjs');
require.cache[require.resolve('./email.ts')] =
require.cache[require.resolve('./rollup/email.cjs')];
console.log('Testing email.cjs...\n');
}

5
email.ts Normal file
View File

@ -0,0 +1,5 @@
export * as client from './smtp/client';
export * as message from './smtp/message';
export * as date from './smtp/date';
export * as smtp from './smtp/smtp';
export * as error from './smtp/error';

View File

@ -1,55 +1,64 @@
{ {
"name": "emailjs", "name": "emailjs",
"description": "send text/html emails and attachments (files, streams and strings) from node.js to any smtp server", "description": "send text/html emails and attachments (files, streams and strings) from node.js to any smtp server",
"version": "2.2.0", "version": "2.2.0",
"author": "eleith", "author": "eleith",
"contributors": [ "contributors": [
"izuzak", "izuzak",
"Hiverness", "Hiverness",
"mscdex", "mscdex",
"jimmybergman", "jimmybergman",
"zackschuster" "zackschuster"
], ],
"repository": { "repository": {
"type": "git", "type": "git",
"url": "http://github.com/eleith/emailjs.git" "url": "http://github.com/eleith/emailjs.git"
}, },
"dependencies": { "dependencies": {
"addressparser": "^0.3.2", "addressparser": "1.0.1",
"emailjs-mime-codec": "^2.0.7" "emailjs-mime-codec": "2.0.9"
}, },
"devDependencies": { "type": "module",
"chai": "^4.1.2", "devDependencies": {
"eslint": "^5.1.0", "@ledge/configs": "23.0.0",
"eslint-config-prettier": "^2.9.0", "@rollup/plugin-commonjs": "12.0.0",
"eslint-plugin-mocha": "^5.1.0", "@rollup/plugin-node-resolve": "8.0.0",
"eslint-plugin-prettier": "^2.6.2", "@rollup/plugin-typescript": "4.1.2",
"mailparser": "^2.2.0", "@types/mailparser": "2.7.3",
"mocha": "^5.2.0", "@types/smtp-server": "3.5.4",
"prettier": "^1.13.7", "@typescript-eslint/eslint-plugin": "3.0.1",
"rollup": "^0.62.0", "@typescript-eslint/parser": "3.0.1",
"rollup-plugin-commonjs": "^9.1.3", "ava": "3.8.2",
"rollup-plugin-node-resolve": "^3.3.0", "eslint": "7.1.0",
"smtp-server": "^3.4.6" "eslint-config-prettier": "6.11.0",
}, "eslint-plugin-prettier": "3.1.3",
"engine": [ "mailparser": "2.7.7",
"node >= 6" "prettier": "2.0.5",
], "rollup": "2.10.9",
"main": "email.js", "smtp-server": "3.6.0",
"scripts": { "ts-node": "8.10.1",
"rollup": "rollup -c rollup.config.js && npm run rollup:test", "tslib": "2.0.0",
"rollup:test": "npm run test -- --file rollup/email.bundle.test.js", "typescript": "3.9.3"
"test": "mocha" },
}, "engine": [
"license": "MIT", "node >= 10"
"eslintIgnore": [ ],
"rollup.config.js", "files": [
"rollup/email.bundle.js", "email.ts",
"email.esm.js" "smtp",
], "rollup"
"prettier": { ],
"singleQuote": true, "main": "./rollup/email.cjs",
"trailingComma": "es5", "types": "./email.ts",
"useTabs": true "exports": {
} "import": "./rollup/email.mjs",
"require": "./rollup/email.cjs"
},
"scripts": {
"build": "rollup -c rollup.config.ts",
"lint": "eslint *.ts \"+(smtp|test)/*.ts\"",
"test": "ava --serial",
"test-cjs": "npm run build && npm run test -- --node-arguments='--title=cjs'"
},
"license": "MIT"
} }

View File

@ -1,17 +0,0 @@
import commonjs from 'rollup-plugin-commonjs';
import resolve from 'rollup-plugin-node-resolve';
export default {
input: 'email.esm.js',
output: {
file: 'rollup/email.bundle.js',
format: 'cjs',
interop: false,
freeze: false,
},
external: require('module').builtinModules,
plugins: [
resolve(),
commonjs(),
]
};

30
rollup.config.ts Normal file
View File

@ -0,0 +1,30 @@
import module from 'module';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';
export default {
input: 'email.ts',
output: [
{
file: 'rollup/email.cjs',
format: 'cjs',
interop: false,
freeze: false,
sourcemap: true,
},
{
file: 'rollup/email.mjs',
format: 'es',
interop: false,
freeze: false,
sourcemap: true,
},
],
external: module.builtinModules,
plugins: [
resolve(),
commonjs(),
typescript({ removeComments: false, include: ['email.ts', 'smtp/*.ts'] }),
],
};

View File

@ -1,4 +0,0 @@
require('./email.bundle.js');
require.cache[require.resolve('../email.js')] =
require.cache[require.resolve('./email.bundle.js')];
console.log('Testing email.bundle.js...');

File diff suppressed because it is too large Load Diff

1
rollup/email.cjs.map Normal file

File diff suppressed because one or more lines are too long

16124
rollup/email.mjs Normal file

File diff suppressed because one or more lines are too long

1
rollup/email.mjs.map Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,87 +1,53 @@
const { SMTP, state } = require('./smtp'); import addressparser from 'addressparser';
const { Message, create } = require('./message'); import { Message } from './message';
const addressparser = require('addressparser'); import type { MessageAttachment, MessageHeaders } from './message';
import { SMTPConnection, SMTPState } from './smtp';
import type { SMTPConnectionOptions } from './smtp';
export interface MessageStack {
callback: (error: Error | null, message: Message) => void;
message: Message;
attachment: MessageAttachment;
text: string;
returnPath: string;
from: string;
to: ReturnType<typeof addressparser>;
cc: string[];
bcc: string[];
}
export class Client {
public readonly smtp: SMTPConnection;
public readonly queue: MessageStack[] = [];
protected sending = false;
protected ready = false;
protected timer: NodeJS.Timer | null = null;
class Client {
/** /**
* @typedef {Object} MessageStack * @param {SMTPConnectionOptions} server smtp options
* @property {function(Error, Message): void} [callback]
* @property {Message} [message]
* @property {string} [returnPath]
* @property {string} [from]
* @property {string} [subject]
* @property {string|Array} [to]
* @property {Array} [cc]
* @property {Array} [bcc]
* @property {string} [text]
* @property {*} [attachment]
*
* @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
*/ */
constructor(server) { constructor(server: Partial<SMTPConnectionOptions>) {
this.smtp = new SMTP(server); this.smtp = new SMTPConnection(server);
//this.smtp.debug(1); //this.smtp.debug(1);
/**
* @type {MessageStack[]}
*/
this.queue = [];
/**
* @type {NodeJS.Timer}
*/
this.timer = null;
/**
* @type {boolean}
*/
this.sending = false;
/**
* @type {boolean}
*/
this.ready = false;
} }
/** /**
* @param {Message|MessageStack} msg msg * @public
* @param {function(Error, MessageStack): void} callback callback * @param {Message} msg the message to send
* @param {function(err: Error, msg: Message): void} callback sss
* @returns {void} * @returns {void}
*/ */
send(msg, callback) { public send(msg: Message, callback: (err: Error, msg: Message) => void) {
/** const message: Message | null =
* @type {Message}
*/
const message =
msg instanceof Message msg instanceof Message
? msg ? msg
: this._canMakeMessage(msg) : this._canMakeMessage(msg)
? create(msg) ? new Message(msg)
: null; : null;
if (message == null) { if (message == null) {
callback( callback(new Error('message is not a valid Message instance'), msg);
new Error('message is not a valid Message instance'),
/** @type {MessageStack} */ (msg)
);
return; return;
} }
@ -91,8 +57,13 @@ class Client {
message, message,
to: addressparser(message.header.to), to: addressparser(message.header.to),
from: addressparser(message.header.from)[0].address, from: addressparser(message.header.from)[0].address,
callback: (callback || function() {}).bind(this), callback: (
}; callback ||
function () {
/* ø */
}
).bind(this),
} as MessageStack;
if (message.header.cc) { if (message.header.cc) {
stack.to = stack.to.concat(addressparser(message.header.cc)); stack.to = stack.to.concat(addressparser(message.header.cc));
@ -114,49 +85,51 @@ class Client {
this.queue.push(stack); this.queue.push(stack);
this._poll(); this._poll();
} else { } else {
callback(new Error(why), /** @type {MessageStack} */ (msg)); callback(new Error(why), msg);
} }
}); });
} }
/** /**
* @private * @protected
* @returns {void} * @returns {void}
*/ */
_poll() { protected _poll() {
clearTimeout(this.timer); if (this.timer != null) {
clearTimeout(this.timer);
}
if (this.queue.length) { if (this.queue.length) {
if (this.smtp.state() == state.NOTCONNECTED) { if (this.smtp.state() == SMTPState.NOTCONNECTED) {
this._connect(this.queue[0]); this._connect(this.queue[0]);
} else if ( } else if (
this.smtp.state() == state.CONNECTED && this.smtp.state() == SMTPState.CONNECTED &&
!this.sending && !this.sending &&
this.ready this.ready
) { ) {
this._sendmail(this.queue.shift()); this._sendmail(this.queue.shift() as MessageStack);
} }
} }
// wait around 1 seconds in case something does come in, // wait around 1 seconds in case something does come in,
// otherwise close out SMTP connection if still open // otherwise close out SMTP connection if still open
else if (this.smtp.state() == state.CONNECTED) { else if (this.smtp.state() == SMTPState.CONNECTED) {
this.timer = setTimeout(() => this.smtp.quit(), 1000); this.timer = setTimeout(() => this.smtp.quit(), 1000);
} }
} }
/** /**
* @private * @protected
* @param {MessageStack} stack stack * @param {MessageStack} stack stack
* @returns {void} * @returns {void}
*/ */
_connect(stack) { protected _connect(stack: MessageStack) {
/** /**
* @param {Error} err callback error * @param {Error} err callback error
* @returns {void} * @returns {void}
*/ */
const connect = err => { const connect = (err: Error) => {
if (!err) { if (!err) {
const begin = err => { const begin = (err: Error) => {
if (!err) { if (!err) {
this.ready = true; this.ready = true;
this._poll(); this._poll();
@ -188,11 +161,11 @@ class Client {
} }
/** /**
* @private * @protected
* @param {MessageStack} msg message stack * @param {MessageStack} msg message stack
* @returns {boolean} can make message * @returns {boolean} can make message
*/ */
_canMakeMessage(msg) { protected _canMakeMessage(msg: MessageHeaders) {
return ( return (
msg.from && msg.from &&
(msg.to || msg.cc || msg.bcc) && (msg.to || msg.cc || msg.bcc) &&
@ -201,13 +174,15 @@ class Client {
} }
/** /**
* @private * @protected
* @param {*} attachment attachment * @param {*} attachment attachment
* @returns {boolean} does contain * @returns {*} whether the attachment contains inlined html
*/ */
_containsInlinedHtml(attachment) { protected _containsInlinedHtml(
attachment: MessageAttachment | MessageAttachment[]
) {
if (Array.isArray(attachment)) { if (Array.isArray(attachment)) {
return attachment.some(att => { return attachment.some((att) => {
return this._isAttachmentInlinedHtml(att); return this._isAttachmentInlinedHtml(att);
}); });
} else { } else {
@ -216,11 +191,11 @@ class Client {
} }
/** /**
* @private * @protected
* @param {*} attachment attachment * @param {MessageAttachment} attachment attachment
* @returns {boolean} is inlined * @returns {boolean} whether the attachment is inlined html
*/ */
_isAttachmentInlinedHtml(attachment) { protected _isAttachmentInlinedHtml(attachment: MessageAttachment) {
return ( return (
attachment && attachment &&
(attachment.data || attachment.path) && (attachment.data || attachment.path) &&
@ -229,17 +204,17 @@ class Client {
} }
/** /**
* @private * @protected
* @param {MessageStack} stack stack * @param {MessageStack} stack stack
* @param {function(MessageStack): void} next next * @param {function(MessageStack): void} next next
* @returns {function(Error): void} callback * @returns {function(Error): void} callback
*/ */
_sendsmtp(stack, next) { protected _sendsmtp(stack: MessageStack, next: (msg: MessageStack) => void) {
/** /**
* @param {Error} [err] error * @param {Error} [err] error
* @returns {void} * @returns {void}
*/ */
return err => { return (err: Error) => {
if (!err && next) { if (!err && next) {
next.apply(this, [stack]); next.apply(this, [stack]);
} else { } else {
@ -251,27 +226,27 @@ class Client {
} }
/** /**
* @private * @protected
* @param {MessageStack} stack stack * @param {MessageStack} stack stack
* @returns {void} * @returns {void}
*/ */
_sendmail(stack) { protected _sendmail(stack: MessageStack) {
const from = stack.returnPath || stack.from; const from = stack.returnPath || stack.from;
this.sending = true; this.sending = true;
this.smtp.mail(this._sendsmtp(stack, this._sendrcpt), '<' + from + '>'); this.smtp.mail(this._sendsmtp(stack, this._sendrcpt), '<' + from + '>');
} }
/** /**
* @private * @protected
* @param {MessageStack} stack stack * @param {MessageStack} stack stack
* @returns {void} * @returns {void}
*/ */
_sendrcpt(stack) { protected _sendrcpt(stack: MessageStack) {
if (stack.to == null || typeof stack.to === 'string') { if (stack.to == null || typeof stack.to === 'string') {
throw new TypeError('stack.to must be array'); throw new TypeError('stack.to must be array');
} }
const to = stack.to.shift().address; const to = stack.to.shift()?.address;
this.smtp.rcpt( this.smtp.rcpt(
this._sendsmtp(stack, stack.to.length ? this._sendrcpt : this._senddata), this._sendsmtp(stack, stack.to.length ? this._sendrcpt : this._senddata),
`<${to}>` `<${to}>`
@ -279,23 +254,23 @@ class Client {
} }
/** /**
* @private * @protected
* @param {MessageStack} stack stack * @param {MessageStack} stack stack
* @returns {void} * @returns {void}
*/ */
_senddata(stack) { protected _senddata(stack: MessageStack) {
this.smtp.data(this._sendsmtp(stack, this._sendmessage)); this.smtp.data(this._sendsmtp(stack, this._sendmessage));
} }
/** /**
* @private * @protected
* @param {MessageStack} stack stack * @param {MessageStack} stack stack
* @returns {void} * @returns {void}
*/ */
_sendmessage(stack) { protected _sendmessage(stack: MessageStack) {
const stream = stack.message.stream(); const stream = stack.message.stream();
stream.on('data', data => this.smtp.message(data)); stream.on('data', (data) => this.smtp.message(data));
stream.on('end', () => { stream.on('end', () => {
this.smtp.data_end( this.smtp.data_end(
this._sendsmtp(stack, () => this._senddone(null, stack)) this._sendsmtp(stack, () => this._senddone(null, stack))
@ -304,29 +279,21 @@ class Client {
// there is no way to cancel a message while in the DATA portion, // there is no way to cancel a message while in the DATA portion,
// so we have to close the socket to prevent a bad email from going out // so we have to close the socket to prevent a bad email from going out
stream.on('error', err => { stream.on('error', (err) => {
this.smtp.close(); this.smtp.close();
this._senddone(err, stack); this._senddone(err, stack);
}); });
} }
/** /**
* @private * @protected
* @param {Error} err err * @param {Error} err err
* @param {MessageStack} stack stack * @param {MessageStack} stack stack
* @returns {void} * @returns {void}
*/ */
_senddone(err, stack) { protected _senddone(err: Error | null, stack: MessageStack) {
this.sending = false; this.sending = false;
stack.callback(err, stack.message); stack.callback(err, stack.message);
this._poll(); this._poll();
} }
} }
exports.Client = Client;
/**
* @param {SMTPOptions} server smtp options
* @returns {Client} the client
*/
exports.connect = server => new Client(server);

View File

@ -1,9 +1,9 @@
/** /**
* @param {Date} [date] an optional date to convert to RFC2822 format * @param {Date} [date] an optional date to convert to RFC2822 format
* @param {boolean} [useUtc=false] whether to parse the date as UTC (default: false) * @param {boolean} [useUtc] whether to parse the date as UTC (default: false)
* @returns {string} the converted date * @returns {string} the converted date
*/ */
function getRFC2822Date(date = new Date(), useUtc = false) { export function getRFC2822Date(date = new Date(), useUtc = false) {
if (useUtc) { if (useUtc) {
return getRFC2822DateUTC(date); return getRFC2822DateUTC(date);
} }
@ -27,12 +27,9 @@ function getRFC2822Date(date = new Date(), useUtc = false) {
* @param {Date} [date] an optional date to convert to RFC2822 format (UTC) * @param {Date} [date] an optional date to convert to RFC2822 format (UTC)
* @returns {string} the converted date * @returns {string} the converted date
*/ */
function getRFC2822DateUTC(date = new Date()) { export function getRFC2822DateUTC(date = new Date()) {
const dates = date.toUTCString().split(' '); const dates = date.toUTCString().split(' ');
dates.pop(); // remove timezone dates.pop(); // remove timezone
dates.push('+0000'); dates.push('+0000');
return dates.join(' '); return dates.join(' ');
} }
exports.getRFC2822Date = getRFC2822Date;
exports.getRFC2822DateUTC = getRFC2822DateUTC;

View File

@ -1,86 +0,0 @@
class SMTPError extends Error {
/**
* @param {string} message the error message
*/
constructor(message) {
super(message);
/**
* @type {number}
*/
this.code = null;
/**
* @type {*}
*/
this.smtp = null;
/**
* @type {Error}
*/
this.previous = null;
}
}
/**
* @param {string} message the error message
* @param {number} code the error code
* @param {Error} [error] an error object
* @param {*} [smtp] smtp
* @returns {SMTPError} an smtp error object
*/
module.exports = function(message, code, error, smtp) {
const err = new SMTPError(
error != null && error.message ? `${message} (${error.message})` : message
);
err.code = code;
err.smtp = smtp;
if (error) {
err.previous = error;
}
return err;
};
/**
* @type {1}
*/
module.exports.COULDNOTCONNECT = 1;
/**
* @type {2}
*/
module.exports.BADRESPONSE = 2;
/**
* @type {3}
*/
module.exports.AUTHFAILED = 3;
/**
* @type {4}
*/
module.exports.TIMEDOUT = 4;
/**
* @type {5}
*/
module.exports.ERROR = 5;
/**
* @type {6}
*/
module.exports.NOCONNECTION = 6;
/**
* @type {7}
*/
module.exports.AUTHNOTSUPPORTED = 7;
/**
* @type {8}
*/
module.exports.CONNECTIONCLOSED = 8;
/**
* @type {9}
*/
module.exports.CONNECTIONENDED = 9;
/**
* @type {10}
*/
module.exports.CONNECTIONAUTH = 10;

45
smtp/error.ts Normal file
View File

@ -0,0 +1,45 @@
/**
* @readonly
* @enum
*/
export const SMTPErrorStates = {
COULDNOTCONNECT: 1,
BADRESPONSE: 2,
AUTHFAILED: 3,
TIMEDOUT: 4,
ERROR: 5,
NOCONNECTION: 6,
AUTHNOTSUPPORTED: 7,
CONNECTIONCLOSED: 8,
CONNECTIONENDED: 9,
CONNECTIONAUTH: 10,
} as const;
class SMTPError extends Error {
public code: number | null = null;
public smtp: unknown = null;
public previous: Error | null = null;
constructor(message: string) {
super(message);
}
}
export function makeSMTPError(
message: string,
code: number,
error?: Error | null,
smtp?: unknown
) {
const msg = error?.message ? `${message} (${error.message})` : message;
const err = new SMTPError(msg);
err.code = code;
err.smtp = smtp;
if (error) {
err.previous = error;
}
return err;
}

View File

@ -1,38 +1,81 @@
const fs = require('fs'); import fs from 'fs';
const { hostname } = require('os'); import type { PathLike } from 'fs';
const { Stream } = require('stream'); import { hostname } from 'os';
const addressparser = require('addressparser'); import { Stream } from 'stream';
const { mimeWordEncode } = require('emailjs-mime-codec'); import type { Duplex } from 'stream';
const { getRFC2822Date } = require('./date'); import addressparser from 'addressparser';
import { mimeWordEncode } from 'emailjs-mime-codec';
const CRLF = '\r\n'; import { getRFC2822Date } from './date';
const CRLF = '\r\n' as const;
/** /**
* MIME standard wants 76 char chunks when sending out. * MIME standard wants 76 char chunks when sending out.
* @type {76}
*/ */
const MIMECHUNK = 76; export const MIMECHUNK = 76 as const;
/** /**
* meets both base64 and mime divisibility * meets both base64 and mime divisibility
* @type {456}
*/ */
const MIME64CHUNK = /** @type {456} */ (MIMECHUNK * 6); export const MIME64CHUNK = (MIMECHUNK * 6) as 456;
/** /**
* size of the message stream buffer * size of the message stream buffer
* @type {12768}
*/ */
const BUFFERSIZE = /** @type {12768} */ (MIMECHUNK * 24 * 7); export const BUFFERSIZE = (MIMECHUNK * 24 * 7) as 12768;
export interface MessageAttachmentHeaders {
[index: string]: string | undefined;
'content-type'?: string;
'content-transfer-encoding'?: BufferEncoding | '7bit' | '8bit';
'content-disposition'?: string;
}
export interface AlternateMessageAttachment {
[index: string]:
| string
| boolean
| MessageAttachment
| MessageAttachment[]
| MessageAttachmentHeaders
| Duplex
| PathLike
| undefined;
name?: string;
headers?: MessageAttachmentHeaders;
inline: boolean;
alternative?: MessageAttachment | boolean;
related?: MessageAttachment[];
data: string;
encoded?: boolean;
stream?: Duplex;
path?: PathLike;
}
export interface MessageAttachment extends AlternateMessageAttachment {
type: string;
charset: string;
method: string;
}
export interface MessageHeaders {
[index: string]: string | null | MessageAttachment | MessageAttachment[];
'content-type': string;
'message-id': string;
'return-path': string | null;
date: string;
from: string;
to: string;
cc: string;
bcc: string;
subject: string;
text: string | null;
attachment: MessageAttachment | MessageAttachment[];
}
/**
* @type {number}
*/
let counter = 0; let counter = 0;
/**
* @returns {string} the generated boundary
*/
function generate_boundary() { function generate_boundary() {
let text = ''; let text = '';
const possible = const possible =
@ -45,12 +88,8 @@ function generate_boundary() {
return text; return text;
} }
/** function convertPersonToAddress(person: string) {
* @param {string} l the person to parse into an address return addressparser(person)
* @returns {string} the parsed address
*/
function person2address(l) {
return addressparser(l)
.map(({ name, address }) => { .map(({ name, address }) => {
return name return name
? `${mimeWordEncode(name).replace(/,/g, '=2C')} <${address}>` ? `${mimeWordEncode(name).replace(/,/g, '=2C')} <${address}>`
@ -59,46 +98,31 @@ function person2address(l) {
.join(', '); .join(', ');
} }
/** function convertDashDelimitedTextToSnakeCase(text: string) {
* @param {string} header_name the header name to fix return text
* @returns {string} the fixed header name
*/
function fix_header_name_case(header_name) {
return header_name
.toLowerCase() .toLowerCase()
.replace(/^(.)|-(.)/g, match => match.toUpperCase()); .replace(/^(.)|-(.)/g, (match) => match.toUpperCase());
} }
class Message { export class Message {
/** public readonly attachments: MessageAttachment[] = [];
* @typedef {Object} MessageHeaders public readonly header: Partial<MessageHeaders> = {
* @property {string?} content-type 'message-id': `<${new Date().getTime()}.${counter++}.${
* @property {string} [subject] process.pid
* @property {string} [text] }@${hostname()}>`,
* @property {MessageAttachment} [attachment] date: getRFC2822Date(),
* @param {MessageHeaders} headers hash of message headers };
*/ public readonly content: string = 'text/plain; charset=utf-8';
constructor(headers) { public readonly text?: string;
this.attachments = []; public alternative: AlternateMessageAttachment | null = null;
/** constructor(headers: Partial<MessageHeaders>) {
* @type {MessageAttachment}
*/
this.alternative = null;
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) { for (const header in headers) {
// allow user to override default content-type to override charset or send a single non-text message // allow user to override default content-type to override charset or send a single non-text message
if (/^content-type$/i.test(header)) { if (/^content-type$/i.test(header)) {
this.content = headers[header]; this.content = headers[header] as string;
} else if (header === 'text') { } else if (header === 'text') {
this.text = headers[header]; this.text = headers[header] as string;
} else if ( } else if (
header === 'attachment' && header === 'attachment' &&
typeof headers[header] === 'object' typeof headers[header] === 'object'
@ -108,13 +132,15 @@ class Message {
for (let i = 0; i < attachment.length; i++) { for (let i = 0; i < attachment.length; i++) {
this.attach(attachment[i]); this.attach(attachment[i]);
} }
} else { } else if (attachment != null) {
this.attach(attachment); this.attach(attachment);
} }
} else if (header === 'subject') { } else if (header === 'subject') {
this.header.subject = mimeWordEncode(headers.subject); this.header.subject = mimeWordEncode(headers.subject);
} else if (/^(cc|bcc|to|from)/i.test(header)) { } else if (/^(cc|bcc|to|from)/i.test(header)) {
this.header[header.toLowerCase()] = person2address(headers[header]); this.header[header.toLowerCase()] = convertPersonToAddress(
headers[header] as string
);
} else { } else {
// allow any headers the user wants to set?? // allow any headers the user wants to set??
// if(/cc|bcc|to|from|reply-to|sender|subject|date|message-id/i.test(header)) // if(/cc|bcc|to|from|reply-to|sender|subject|date|message-id/i.test(header))
@ -124,22 +150,11 @@ class Message {
} }
/** /**
* @public
* @param {MessageAttachment} options attachment options * @param {MessageAttachment} options attachment options
* @returns {Message} the current instance for chaining * @returns {Message} the current instance for chaining
*/ */
attach(options) { public attach(options: MessageAttachment): Message {
/*
legacy support, will remove eventually...
arguments -> (path, type, name, headers)
*/
if (typeof options === 'string' && arguments.length > 1) {
options = {
path: options,
type: arguments[1],
name: arguments[2],
};
}
// sender can specify an attachment as an alternative // sender can specify an attachment as an alternative
if (options.alternative) { if (options.alternative) {
this.alternative = options; this.alternative = options;
@ -160,7 +175,7 @@ class Message {
* @param {string} [charset='utf-8'] the charset to encode as * @param {string} [charset='utf-8'] the charset to encode as
* @returns {Message} the current Message instance * @returns {Message} the current Message instance
*/ */
attach_alternative(html, charset) { attach_alternative(html: string, charset: string): Message {
this.alternative = { this.alternative = {
data: html, data: html,
charset: charset || 'utf-8', charset: charset || 'utf-8',
@ -172,10 +187,11 @@ class Message {
} }
/** /**
* @public
* @param {function(boolean, string): void} callback This callback is displayed as part of the Requester class. * @param {function(boolean, string): void} callback This callback is displayed as part of the Requester class.
* @returns {void} * @returns {void}
*/ */
valid(callback) { public valid(callback: (arg0: boolean, arg1?: string) => void) {
if (!this.header.from) { if (!this.header.from) {
callback(false, 'message does not have a valid sender'); callback(false, 'message does not have a valid sender');
} }
@ -185,9 +201,9 @@ class Message {
} else if (this.attachments.length === 0) { } else if (this.attachments.length === 0) {
callback(true, undefined); callback(true, undefined);
} else { } else {
const failed = []; const failed: string[] = [];
this.attachments.forEach(attachment => { this.attachments.forEach((attachment) => {
if (attachment.path) { if (attachment.path) {
if (fs.existsSync(attachment.path) == false) { if (fs.existsSync(attachment.path) == false) {
failed.push(`${attachment.path} does not exist`); failed.push(`${attachment.path} does not exist`);
@ -206,132 +222,103 @@ class Message {
} }
/** /**
* returns a stream of the current message * @public
* @returns {MessageStream} a stream of the current message * @returns {*} a stream of the current message
*/ */
stream() { public stream() {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
return new MessageStream(this); return new MessageStream(this);
} }
/** /**
* @public
* @param {function(Error, string): void} callback the function to call with the error and buffer * @param {function(Error, string): void} callback the function to call with the error and buffer
* @returns {void} * @returns {void}
*/ */
read(callback) { public read(callback: (err: Error, buffer: string) => void) {
let buffer = ''; let buffer = '';
const str = this.stream(); const str = this.stream();
str.on('data', data => (buffer += data)); str.on('data', (data) => (buffer += data));
str.on('end', err => callback(err, buffer)); str.on('end', (err) => callback(err, buffer));
str.on('error', err => callback(err, buffer)); str.on('error', (err) => callback(err, buffer));
} }
} }
/**
* @typedef {Object} MessageAttachmentHeaders
* @property {string} content-type
* @property {string} content-transfer-encoding
* @property {string} content-disposition
*/
/**
* @typedef {Object} MessageAttachment
* @property {string} [name]
* @property {string} [type]
* @property {string} [charset]
* @property {string} [method]
* @property {string} [path]
* @property {NodeJS.ReadWriteStream} [stream]
* @property {boolean} [inline]
* @property {MessageAttachment} [alternative]
* @property {MessageAttachment[]} [related]
* @property {*} [encoded]
* @property {*} [data]
* @property {MessageAttachmentHeaders} [headers]
*/
class MessageStream extends Stream { class MessageStream extends Stream {
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) { constructor(private message: Message) {
super(); super();
/** /**
* @type {Message} * @param {string} [data] the data to output
*/ * @param {Function} [callback] the function
this.message = message; * @param {any[]} [args] array of arguments to pass to the callback
/**
* @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} * @returns {void}
*/ */
const output_mixed = () => { const output = (data: string) => {
const boundary = generate_boundary(); // can we buffer the data?
output( if (this.buffer != null) {
`Content-Type: multipart/mixed; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}` const bytes = Buffer.byteLength(data);
);
if (this.message.alternative == null) { if (bytes + this.bufferIndex < this.buffer.length) {
output_text(this.message); this.buffer.write(data, this.bufferIndex);
output_message(boundary, this.message.attachments, 0, close); this.bufferIndex += bytes;
} else { }
const cb = () => // we can't buffer the data, so ship it out!
output_message(boundary, this.message.attachments, 0, close); else if (bytes > this.buffer.length) {
output_alternative(this.message, cb); if (this.bufferIndex) {
} this.emit(
}; 'data',
this.buffer.toString('utf-8', 0, this.bufferIndex)
/** );
* @param {string} boundary the boundary text between outputs this.bufferIndex = 0;
* @param {MessageAttachment[]} list the list of potential messages to output }
* @param {number} index the index of the list item to output
* @param {function(): void} callback the function to call if index is greater than upper bound const loops = Math.ceil(data.length / this.buffer.length);
* @returns {void} let loop = 0;
*/ while (loop < loops) {
const output_message = (boundary, list, index, callback) => { this.emit(
if (index < list.length) { 'data',
output(`--${boundary}${CRLF}`); data.substring(
if (list[index].related) { this.buffer.length * loop,
output_related(list[index], () => this.buffer.length * (loop + 1)
output_message(boundary, list, index + 1, callback) )
); );
} else { loop++;
output_attachment(list[index], () => }
output_message(boundary, list, index + 1, callback) } // we need to clean out the buffer, it is getting full
); else {
if (!this.paused) {
this.emit(
'data',
this.buffer.toString('utf-8', 0, this.bufferIndex)
);
this.buffer.write(data, 0);
this.bufferIndex = bytes;
} else {
// we can't empty out the buffer, so let's wait till we resume before adding to it
this.once('resume', () => output(data));
}
} }
} else {
output(`${CRLF}--${boundary}--${CRLF}${CRLF}`);
callback();
} }
}; };
/** /**
* @param {MessageAttachment} attachment the metadata to use as headers * @param {MessageAttachment | AlternateMessageAttachment} [attachment] the attachment whose headers you would like to output
* @returns {void} * @returns {void}
*/ */
const output_attachment_headers = attachment => { const output_attachment_headers = (
let data = []; attachment: MessageAttachment | AlternateMessageAttachment
const headers = { ) => {
let data: string[] = [];
const headers: Partial<MessageHeaders> = {
'content-type': 'content-type':
attachment.type + attachment.type +
(attachment.charset ? `; charset=${attachment.charset}` : '') + (attachment.charset ? `; charset=${attachment.charset}` : '') +
@ -339,19 +326,23 @@ class MessageStream extends Stream {
'content-transfer-encoding': 'base64', 'content-transfer-encoding': 'base64',
'content-disposition': attachment.inline 'content-disposition': attachment.inline
? 'inline' ? 'inline'
: `attachment; filename="${mimeWordEncode(attachment.name)}"`, : `attachment; filename="${mimeWordEncode(
attachment.name as string
)}"`,
}; };
// allow sender to override default headers // allow sender to override default headers
for (const header in attachment.headers || {}) { if (attachment.headers != null) {
headers[header.toLowerCase()] = attachment.headers[header]; for (const header in attachment.headers) {
headers[header.toLowerCase()] = attachment.headers[header];
}
} }
for (const header in headers) { for (const header in headers) {
data = data.concat([ data = data.concat([
fix_header_name_case(header), convertDashDelimitedTextToSnakeCase(header),
': ', ': ',
headers[header], headers[header] as string,
CRLF, CRLF,
]); ]);
} }
@ -360,52 +351,38 @@ class MessageStream extends Stream {
}; };
/** /**
* @param {MessageAttachment} attachment the metadata to use as headers * @param {string} data the data to output as base64
* @param {function(): void} callback the function to call after output is finished * @param {function(): void} [callback] the function to call after output is finished
* @returns {void} * @returns {void}
*/ */
const output_attachment = (attachment, callback) => { const output_base64 = (data: string, callback?: () => void) => {
const build = attachment.path const loops = Math.ceil(data.length / MIMECHUNK);
? output_file let loop = 0;
: attachment.stream while (loop < loops) {
? output_stream output(data.substring(MIMECHUNK * loop, MIMECHUNK * (loop + 1)) + CRLF);
: output_data; loop++;
output_attachment_headers(attachment); }
build(attachment, callback); if (callback) {
callback();
}
}; };
/** const output_file = (
* @param {MessageAttachment} attachment the metadata to use as headers attachment: MessageAttachment | AlternateMessageAttachment,
* @param {function(): void} callback the function to call after output is finished next: (err: NodeJS.ErrnoException | null) => void
* @returns {void} ) => {
*/
const output_data = (attachment, callback) => {
output_base64(
attachment.encoded
? attachment.data
: Buffer.from(attachment.data).toString('base64'),
callback
);
};
/**
* @param {MessageAttachment} attachment the metadata to use as headers
* @param {function(NodeJS.ErrnoException): void} next the function to call when the file is closed
* @returns {void}
*/
const output_file = (attachment, next) => {
const chunk = MIME64CHUNK * 16; const chunk = MIME64CHUNK * 16;
const buffer = Buffer.alloc(chunk); const buffer = Buffer.alloc(chunk);
const closed = fd => fs.closeSync(fd); const closed = (fd: number) => fs.closeSync(fd);
/** /**
* @param {Error} err the error to emit * @param {Error} err the error to emit
* @param {number} fd the file descriptor * @param {number} fd the file descriptor
* @returns {void} * @returns {void}
*/ */
const opened = (err, fd) => { const opened = (err: NodeJS.ErrnoException | null, fd: number) => {
if (!err) { if (!err) {
const read = (err, bytes) => { const read = (err: NodeJS.ErrnoException | null, bytes: number) => {
if (!err && this.readable) { if (!err && this.readable) {
let encoding = let encoding =
attachment && attachment.headers attachment && attachment.headers
@ -444,7 +421,7 @@ class MessageStream extends Stream {
} }
}; };
fs.open(attachment.path, 'r', opened); fs.open(attachment.path as PathLike, 'r', opened);
}; };
/** /**
@ -452,20 +429,26 @@ class MessageStream extends Stream {
* @param {function(): void} callback the function to call after output is finished * @param {function(): void} callback the function to call after output is finished
* @returns {void} * @returns {void}
*/ */
const output_stream = (attachment, callback) => { const output_stream = (
if (attachment.stream.readable) { attachment: MessageAttachment | AlternateMessageAttachment,
callback: () => void
) => {
if (attachment.stream != null && attachment.stream.readable) {
let previous = Buffer.alloc(0); let previous = Buffer.alloc(0);
attachment.stream.resume(); attachment.stream.resume();
attachment.stream.on('end', () => { attachment.stream.on('end', () => {
output_base64(previous.toString('base64'), callback); output_base64(previous.toString('base64'), callback);
this.removeListener('pause', attachment.stream.pause); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.removeListener('resume', attachment.stream.resume); this.removeListener('pause', attachment.stream!.pause);
this.removeListener('error', attachment.stream.resume); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.removeListener('resume', attachment.stream!.resume);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.removeListener('error', attachment.stream!.resume);
}); });
attachment.stream.on('data', buff => { attachment.stream.on('data', (buff) => {
// do we have bytes from a previous stream data event? // do we have bytes from a previous stream data event?
let buffer = Buffer.isBuffer(buff) ? buff : Buffer.from(buff); let buffer = Buffer.isBuffer(buff) ? buff : Buffer.from(buff);
@ -492,29 +475,90 @@ class MessageStream extends Stream {
} }
}; };
const output_attachment = (
attachment: MessageAttachment | AlternateMessageAttachment,
callback: () => void
) => {
const build = attachment.path
? output_file
: attachment.stream
? output_stream
: output_data;
output_attachment_headers(attachment);
build(attachment, callback);
};
/** /**
* @param {string} data the data to output as base64 * @param {string} boundary the boundary text between outputs
* @param {function(): void} [callback] the function to call after output is finished * @param {MessageAttachment[]} list the list of potential messages to output
* @param {number} index the index of the list item to output
* @param {function(): void} callback the function to call if index is greater than upper bound
* @returns {void} * @returns {void}
*/ */
const output_base64 = (data, callback) => { const output_message = (
const loops = Math.ceil(data.length / MIMECHUNK); boundary: string,
let loop = 0; list: MessageAttachment[],
while (loop < loops) { index: number,
output(data.substring(MIMECHUNK * loop, MIMECHUNK * (loop + 1)) + CRLF); callback: () => void
loop++; ) => {
} if (index < list.length) {
if (callback) { output(`--${boundary}${CRLF}`);
if (list[index].related) {
output_related(list[index], () =>
output_message(boundary, list, index + 1, callback)
);
} else {
output_attachment(list[index], () =>
output_message(boundary, list, index + 1, callback)
);
}
} else {
output(`${CRLF}--${boundary}--${CRLF}${CRLF}`);
callback(); callback();
} }
}; };
const output_mixed = () => {
const boundary = generate_boundary();
output(
`Content-Type: multipart/mixed; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}`
);
if (this.message.alternative == null) {
output_text(this.message);
output_message(boundary, this.message.attachments, 0, close);
} else {
output_alternative(
// typescript bug; should narrow to { alternative: AlternateMessageAttachment }
this.message as Parameters<typeof output_alternative>[0],
() => output_message(boundary, this.message.attachments, 0, close)
);
}
};
/**
* @param {MessageAttachment} attachment the metadata to use as headers
* @param {function(): void} callback the function to call after output is finished
* @returns {void}
*/
const output_data = (
attachment: MessageAttachment | AlternateMessageAttachment,
callback: () => void
) => {
output_base64(
attachment.encoded
? attachment.data
: Buffer.from(attachment.data).toString('base64'),
callback
);
};
/** /**
* @param {Message} message the message to output * @param {Message} message the message to output
* @returns {void} * @returns {void}
*/ */
const output_text = message => { const output_text = (message: Message) => {
let data = []; let data: string[] = [];
data = data.concat([ data = data.concat([
'Content-Type:', 'Content-Type:',
@ -529,12 +573,36 @@ class MessageStream extends Stream {
output(data.join('')); output(data.join(''));
}; };
/**
* @param {MessageAttachment} message the message to output
* @param {function(): void} callback the function to call after output is finished
* @returns {void}
*/
const output_related = (
message: AlternateMessageAttachment,
callback: () => void
) => {
const boundary = generate_boundary();
output(
`Content-Type: multipart/related; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}`
);
output_attachment(message, () => {
output_message(boundary, message.related ?? [], 0, () => {
output(`${CRLF}--${boundary}--${CRLF}${CRLF}`);
callback();
});
});
};
/** /**
* @param {Message} message the message to output * @param {Message} message the message to output
* @param {function(): void} callback the function to call after output is finished * @param {function(): void} callback the function to call after output is finished
* @returns {void} * @returns {void}
*/ */
const output_alternative = (message, callback) => { const output_alternative = (
message: Message & { alternative: AlternateMessageAttachment },
callback: () => void
) => {
const boundary = generate_boundary(); const boundary = generate_boundary();
output( output(
`Content-Type: multipart/alternative; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}` `Content-Type: multipart/alternative; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}`
@ -557,22 +625,24 @@ class MessageStream extends Stream {
} }
}; };
/** const close = (err?: Error) => {
* @param {MessageAttachment} message the message to output if (err) {
* @param {function(): void} callback the function to call after output is finished this.emit('error', err);
* @returns {void} } else {
*/ this.emit(
const output_related = (message, callback) => { 'data',
const boundary = generate_boundary(); this.buffer?.toString('utf-8', 0, this.bufferIndex) ?? ''
output( );
`Content-Type: multipart/related; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}` this.emit('end');
); }
output_attachment(message, () => { this.buffer = null;
output_message(boundary, message.related, 0, () => { this.bufferIndex = 0;
output(`${CRLF}--${boundary}--${CRLF}${CRLF}`); this.readable = false;
callback(); this.removeAllListeners('resume');
}); this.removeAllListeners('pause');
}); this.removeAllListeners('error');
this.removeAllListeners('data');
this.removeAllListeners('end');
}; };
/** /**
@ -593,18 +663,18 @@ class MessageStream extends Stream {
* @returns {void} * @returns {void}
*/ */
const output_header = () => { const output_header = () => {
let data = []; let data: string[] = [];
for (const header in this.message.header) { for (const header in this.message.header) {
// do not output BCC in the headers (regex) nor custom Object.prototype functions... // do not output BCC in the headers (regex) nor custom Object.prototype functions...
if ( if (
!/bcc/i.test(header) && !/bcc/i.test(header) &&
this.message.header.hasOwnProperty(header) Object.prototype.hasOwnProperty.call(this.message.header, header)
) { ) {
data = data.concat([ data = data.concat([
fix_header_name_case(header), convertDashDelimitedTextToSnakeCase(header),
': ', ': ',
this.message.header[header], this.message.header[header] as string,
CRLF, CRLF,
]); ]);
} }
@ -614,109 +684,36 @@ class MessageStream extends Stream {
output_header_data(); output_header_data();
}; };
/**
* @param {string} data the data to output
* @param {function(...args): void} [callback] the function
* @param {*[]} [args] array of arguments to pass to the callback
* @returns {void}
*/
const output = (data, callback, args) => {
const bytes = Buffer.byteLength(data);
// can we buffer the data?
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) {
if (this.bufferIndex) {
this.emit('data', this.buffer.toString('utf-8', 0, this.bufferIndex));
this.bufferIndex = 0;
}
const loops = Math.ceil(data.length / this.buffer.length);
let loop = 0;
while (loop < loops) {
this.emit(
'data',
data.substring(
this.buffer.length * loop,
this.buffer.length * (loop + 1)
)
);
loop++;
}
} // we need to clean out the buffer, it is getting full
else {
if (!this.paused) {
this.emit('data', this.buffer.toString('utf-8', 0, this.bufferIndex));
this.buffer.write(data, 0);
this.bufferIndex = bytes;
// we could get paused after emitting data...
if (this.paused) {
this.once('resume', () => callback.apply(null, args));
} else if (callback) {
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));
}
}
};
/**
* @param {*} [err] the error to emit
* @returns {void}
*/
const close = err => {
if (err) {
this.emit('error', err);
} else {
this.emit('data', this.buffer.toString('utf-8', 0, this.bufferIndex));
this.emit('end');
}
this.buffer = null;
this.bufferIndex = 0;
this.readable = false;
this.removeAllListeners('resume');
this.removeAllListeners('pause');
this.removeAllListeners('error');
this.removeAllListeners('data');
this.removeAllListeners('end');
};
this.once('destroy', close); this.once('destroy', close);
process.nextTick(output_header); process.nextTick(output_header);
} }
/** /**
* @public
* pause the stream * pause the stream
* @returns {void} * @returns {void}
*/ */
pause() { public pause() {
this.paused = true; this.paused = true;
this.emit('pause'); this.emit('pause');
} }
/** /**
* @public
* resume the stream * resume the stream
* @returns {void} * @returns {void}
*/ */
resume() { public resume() {
this.paused = false; this.paused = false;
this.emit('resume'); this.emit('resume');
} }
/** /**
* @public
* destroy the stream * destroy the stream
* @returns {void} * @returns {void}
*/ */
destroy() { public destroy() {
this.emit( this.emit(
'destroy', 'destroy',
this.bufferIndex > 0 ? { message: 'message stream destroyed' } : null this.bufferIndex > 0 ? { message: 'message stream destroyed' } : null
@ -724,14 +721,11 @@ class MessageStream extends Stream {
} }
/** /**
* @public
* destroy the stream at first opportunity * destroy the stream at first opportunity
* @returns {void} * @returns {void}
*/ */
destroySoon() { public destroySoon() {
this.emit('destroy'); this.emit('destroy');
} }
} }
exports.Message = Message;
exports.BUFFERSIZE = BUFFERSIZE;
exports.create = headers => new Message(headers);

View File

@ -1,139 +0,0 @@
const SMTPError = require('./error');
/**
* @typedef {import('net').Socket} Socket
* @typedef {import('tls').TLSSocket} TLSSocket
*/
class SMTPResponse {
/**
* @constructor
* @param {Socket | TLSSocket} stream the open socket to stream a response from
* @param {number} timeout the time to wait (in milliseconds) before closing the socket
* @param {function(Error): void} onerror the function to call on error
*/
constructor(stream, timeout, onerror) {
let buffer = '';
/**
* @returns {void}
*/
const notify = () => {
if (buffer.length) {
// parse buffer for response codes
const line = buffer.replace('\r', '');
if (
!line
.trim()
.split(/\n/)
.pop()
.match(/^(\d{3})\s/)
) {
return;
}
const match = line ? line.match(/(\d+)\s?(.*)/) : null;
const data =
match !== null
? { code: match[1], message: match[2], data: line }
: { code: -1, data: line };
stream.emit('response', null, data);
buffer = '';
}
};
/**
* @param {Error} err the error object
* @returns {void}
*/
const error = err => {
stream.emit(
'response',
SMTPError('connection encountered an error', SMTPError.ERROR, err)
);
};
/**
* @param {Error} err the error object
* @returns {void}
*/
const timedout = err => {
stream.end();
stream.emit(
'response',
SMTPError(
'timedout while connecting to smtp server',
SMTPError.TIMEDOUT,
err
)
);
};
/**
* @param {string | Buffer} data the data
* @returns {void}
*/
const watch = data => {
if (data !== null) {
buffer += data.toString();
notify();
}
};
/**
* @param {Error} err the error object
* @returns {void}
*/
const close = err => {
stream.emit(
'response',
SMTPError('connection has closed', SMTPError.CONNECTIONCLOSED, err)
);
};
/**
* @param {Error} err the error object
* @returns {void}
*/
const end = err => {
stream.emit(
'response',
SMTPError('connection has ended', SMTPError.CONNECTIONENDED, err)
);
};
/**
* @param {Error} [err] the error object
* @returns {void}
*/
this.stop = err => {
stream.removeAllListeners('response');
stream.removeListener('data', watch);
stream.removeListener('end', end);
stream.removeListener('close', close);
stream.removeListener('error', error);
if (err != null && typeof onerror === 'function') {
onerror(err);
}
};
stream.on('data', watch);
stream.on('end', end);
stream.on('close', close);
stream.on('error', error);
stream.setTimeout(timeout, timedout);
}
}
exports.SMTPResponse = SMTPResponse;
/**
* @param {Socket | TLSSocket} stream the open socket to stream a response from
* @param {number} timeout the time to wait (in milliseconds) before closing the socket
* @param {function(Error): void} onerror the function to call on error
* @returns {SMTPResponse} the smtp response
*/
exports.monitor = (stream, timeout, onerror) =>
new SMTPResponse(stream, timeout, onerror);

112
smtp/response.ts Normal file
View File

@ -0,0 +1,112 @@
import { makeSMTPError, SMTPErrorStates } from './error';
import type { Socket } from 'net';
import type { TLSSocket } from 'tls';
export class SMTPResponse {
public readonly stop: (err?: Error) => void;
constructor(
stream: Socket | TLSSocket,
timeout: number,
onerror: (err: Error) => void
) {
let buffer = '';
const notify = () => {
if (buffer.length) {
// parse buffer for response codes
const line = buffer.replace('\r', '');
if (
!(
line
.trim()
.split(/\n/)
.pop()
?.match(/^(\d{3})\s/) ?? false
)
) {
return;
}
const match = line ? line.match(/(\d+)\s?(.*)/) : null;
const data =
match !== null
? { code: match[1], message: match[2], data: line }
: { code: -1, data: line };
stream.emit('response', null, data);
buffer = '';
}
};
const error = (err: Error) => {
stream.emit(
'response',
makeSMTPError(
'connection encountered an error',
SMTPErrorStates.ERROR,
err
)
);
};
const timedout = (err?: Error) => {
stream.end();
stream.emit(
'response',
makeSMTPError(
'timedout while connecting to smtp server',
SMTPErrorStates.TIMEDOUT,
err
)
);
};
const watch = (data: string | Buffer) => {
if (data !== null) {
buffer += data.toString();
notify();
}
};
const close = (err: Error) => {
stream.emit(
'response',
makeSMTPError(
'connection has closed',
SMTPErrorStates.CONNECTIONCLOSED,
err
)
);
};
const end = (err: Error) => {
stream.emit(
'response',
makeSMTPError(
'connection has ended',
SMTPErrorStates.CONNECTIONENDED,
err
)
);
};
this.stop = (err) => {
stream.removeAllListeners('response');
stream.removeListener('data', watch);
stream.removeListener('end', end);
stream.removeListener('close', close);
stream.removeListener('error', error);
if (err != null && typeof onerror === 'function') {
onerror(err);
}
};
stream.on('data', watch);
stream.on('end', end);
stream.on('close', close);
stream.on('error', error);
stream.setTimeout(timeout, timedout);
}
}

View File

@ -1,75 +1,49 @@
const { Socket } = require('net'); import { Socket } from 'net';
const { createHmac } = require('crypto'); import { createHmac } from 'crypto';
const { hostname } = require('os'); import { hostname } from 'os';
const { connect, createSecureContext, TLSSocket } = require('tls'); import { connect, createSecureContext, TLSSocket } from 'tls';
const { EventEmitter } = require('events'); import { EventEmitter } from 'events';
const SMTPResponse = require('./response'); import { SMTPResponse } from './response';
const SMTPError = require('./error'); import { makeSMTPError, SMTPErrorStates } from './error';
/**
* @readonly
* @type {5000}
*/
const TIMEOUT = 5000;
/**
* @readonly
* @type {25}
*/
const SMTP_PORT = 25;
/**
* @readonly
* @type {465}
*/
const SMTP_SSL_PORT = 465;
/**
* @readonly
* @type {587}
*/
const SMTP_TLS_PORT = 587;
/**
* @readonly
* @type {'\r\n'}
*/
const CRLF = '\r\n';
/** /**
* @readonly * @readonly
* @enum * @enum
*/ */
const AUTH_METHODS = { export const AUTH_METHODS = {
PLAIN: /** @type {'PLAIN'} */ ('PLAIN'), PLAIN: 'PLAIN',
CRAM_MD5: /** @type {'CRAM-MD5'} */ ('CRAM-MD5'), 'CRAM-MD5': 'CRAM-MD5',
LOGIN: /** @type {'LOGIN'} */ ('LOGIN'), LOGIN: 'LOGIN',
XOAUTH2: /** @type {'XOAUTH2'} */ ('XOAUTH2'), XOAUTH2: 'XOAUTH2',
}; } as const;
/** /**
* @readonly * @readonly
* @enum * @enum
*/ */
const SMTPState = { export const SMTPState = {
NOTCONNECTED: /** @type {0} */ (0), NOTCONNECTED: 0,
CONNECTING: /** @type {1} */ (1), CONNECTING: 1,
CONNECTED: /** @type {2} */ (2), CONNECTED: 2,
}; } as const;
/** export const DEFAULT_TIMEOUT = 5000 as const;
* @type {0 | 1}
*/ const SMTP_PORT = 25 as const;
let DEBUG = 0; 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;
/** /**
* @param {...any} args the message(s) to log * @param {...any} args the message(s) to log
* @returns {void} * @returns {void}
*/ */
const log = (...args) => { const log = (...args: any[]) => {
if (DEBUG === 1) { if (DEBUG === 1) {
args.forEach(d => args.forEach((d) =>
console.log( console.log(
typeof d === 'object' typeof d === 'object'
? d instanceof Error ? d instanceof Error
@ -86,35 +60,63 @@ const log = (...args) => {
* @param {...*} args the arguments to apply to the function * @param {...*} args the arguments to apply to the function
* @returns {void} * @returns {void}
*/ */
const caller = (callback, ...args) => { const caller = (callback?: (...rest: any[]) => void, ...args: any[]) => {
if (typeof callback === 'function') { if (typeof callback === 'function') {
callback.apply(null, args); callback(...args);
} }
}; };
class SMTP extends EventEmitter { export interface SMTPSocketOptions {
key: string;
ca: string;
cert: string;
}
export interface SMTPConnectionOptions {
timeout: number | null;
user: string;
password: string;
domain: string;
host: string;
port: number;
ssl: boolean | SMTPSocketOptions;
tls: boolean | SMTPSocketOptions;
authentication: (keyof typeof AUTH_METHODS)[];
logger: (...args: any[]) => void;
}
export interface ConnectOptions {
ssl?: boolean;
}
export class SMTPConnection extends EventEmitter {
public readonly user: () => string;
public readonly password: () => string;
public readonly timeout: number = DEFAULT_TIMEOUT;
protected readonly log = log;
protected readonly authentication: (keyof typeof AUTH_METHODS)[] = [
AUTH_METHODS['CRAM-MD5'],
AUTH_METHODS.LOGIN,
AUTH_METHODS.PLAIN,
AUTH_METHODS.XOAUTH2,
];
protected _state: 0 | 1 | 2 = SMTPState.NOTCONNECTED;
protected _secure = false;
protected loggedin = false;
protected sock: Socket | TLSSocket | null = null;
protected features: { [index: string]: string | boolean } | null = null;
protected monitor: SMTPResponse | null = null;
protected domain = hostname();
protected host = 'localhost';
protected ssl: boolean | SMTPSocketOptions = false;
protected tls: boolean | SMTPSocketOptions = false;
protected port: number;
/** /**
* SMTP class written using python's (2.7) smtplib.py as a base * SMTP class written using python's (2.7) smtplib.py as a base
*
* @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} [options] instance options
*/ */
constructor({ constructor({
timeout, timeout,
@ -127,146 +129,98 @@ class SMTP extends EventEmitter {
tls, tls,
logger, logger,
authentication, authentication,
} = {}) { }: Partial<SMTPConnectionOptions> = {}) {
super(); super();
/** if (Array.isArray(authentication)) {
* @private this.authentication = authentication;
* @type {0 | 1 | 2} }
*/
this._state = SMTPState.NOTCONNECTED;
/** if (typeof timeout === 'number') {
* @private this.timeout = timeout;
* @type {boolean} }
*/
this._secure = false;
/** if (typeof domain === 'string') {
* @type {Socket|TLSSocket} this.domain = domain;
*/ }
this.sock = null;
/** if (typeof host === 'string') {
* @type {{ [i: string]: string | boolean }} this.host = host;
*/ }
this.features = null;
/** if (
* @type {SMTPResponse.SMTPResponse}
*/
this.monitor = null;
/**
* @type {string[]}
*/
this.authentication = Array.isArray(authentication)
? authentication
: [
AUTH_METHODS.CRAM_MD5,
AUTH_METHODS.LOGIN,
AUTH_METHODS.PLAIN,
AUTH_METHODS.XOAUTH2,
];
/**
* @type {number} }
*/
this.timeout = typeof timeout === 'number' ? timeout : TIMEOUT;
/**
* @type {string} }
*/
this.domain = typeof domain === 'string' ? domain : hostname();
/**
* @type {string} }
*/
this.host = typeof host === 'string' ? host : 'localhost';
/**
* @type {boolean|SMTPSocketOptions}
*/
this.ssl =
ssl != null && ssl != null &&
(typeof ssl === 'boolean' || (typeof ssl === 'boolean' ||
(typeof ssl === 'object' && Array.isArray(ssl) === false)) (typeof ssl === 'object' && Array.isArray(ssl) === false))
? ssl ) {
: false; this.ssl = ssl;
}
/** if (
* @type {boolean|SMTPSocketOptions}
*/
this.tls =
tls != null && tls != null &&
(typeof tls === 'boolean' || (typeof tls === 'boolean' ||
(typeof tls === 'object' && Array.isArray(tls) === false)) (typeof tls === 'object' && Array.isArray(tls) === false))
? tls ) {
: false; this.tls = tls;
}
/**
* @type {number}
*/
this.port = port || (ssl ? SMTP_SSL_PORT : tls ? SMTP_TLS_PORT : SMTP_PORT); this.port = port || (ssl ? SMTP_SSL_PORT : tls ? SMTP_TLS_PORT : SMTP_PORT);
/**
* @type {boolean}
*/
this.loggedin = user && password ? false : true; this.loggedin = user && password ? false : true;
// keep these strings hidden when quicky debugging/logging // keep these strings hidden when quicky debugging/logging
this.user = /** @returns {string} */ () => user; this.user = () => user as string;
this.password = /** @returns {string} */ () => password; this.password = () => password as string;
this.log = typeof logger === 'function' ? logger : log; if (typeof logger === 'function') {
this.log = log;
}
} }
/** /**
* @public
* @param {0 | 1} level - * @param {0 | 1} level -
* @returns {void} * @returns {void}
*/ */
debug(level) { public debug(level: 0 | 1) {
DEBUG = level; DEBUG = level;
} }
/** /**
* @returns {number} the current state * @public
* @returns {SMTPState} the current state
*/ */
state() { public state() {
return this._state; return this._state;
} }
/** /**
* @public
* @returns {boolean} whether or not the instance is authorized * @returns {boolean} whether or not the instance is authorized
*/ */
authorized() { public authorized() {
return this.loggedin; return this.loggedin;
} }
/** /**
* @typedef {Object} ConnectOptions * @public
* @property {boolean} [ssl]
*
* @param {function(...*): void} callback function to call after response * @param {function(...*): void} callback function to call after response
* @param {number} [port] the port to use for the connection * @param {number} [port] the port to use for the connection
* @param {string} [host] the hostname to use for the connection * @param {string} [host] the hostname to use for the connection
* @param {ConnectOptions} [options={}] the options * @param {ConnectOptions} [options={}] the options
* @returns {void} * @returns {void}
*/ */
connect(callback, port = this.port, host = this.host, options = {}) { public connect(
callback: (...rest: any[]) => void,
port: number = this.port,
host: string = this.host,
options: ConnectOptions = {}
) {
this.port = port; this.port = port;
this.host = host; this.host = host;
this.ssl = options.ssl || this.ssl; this.ssl = options.ssl || this.ssl;
if (this._state !== SMTPState.NOTCONNECTED) { if (this._state !== SMTPState.NOTCONNECTED) {
this.quit(() => this.quit(() => this.connect(callback, port, host, options));
this.connect(
callback,
port,
host,
options
)
);
} }
/** /**
@ -285,9 +239,9 @@ class SMTP extends EventEmitter {
this.close(true); this.close(true);
caller( caller(
callback, callback,
SMTPError( makeSMTPError(
'could not establish an ssl connection', 'could not establish an ssl connection',
SMTPError.CONNECTIONAUTH SMTPErrorStates.CONNECTIONAUTH
) )
); );
} else { } else {
@ -300,7 +254,7 @@ class SMTP extends EventEmitter {
* @param {Error} err err * @param {Error} err err
* @returns {void} * @returns {void}
*/ */
const connectedErrBack = err => { const connectedErrBack = (err?: Error) => {
if (!err) { if (!err) {
connected(); connected();
} else { } else {
@ -308,12 +262,19 @@ class SMTP extends EventEmitter {
this.log(err); this.log(err);
caller( caller(
callback, callback,
SMTPError('could not connect', SMTPError.COULDNOTCONNECT, err) makeSMTPError(
'could not connect',
SMTPErrorStates.COULDNOTCONNECT,
err
)
); );
} }
}; };
const response = (err, msg) => { const response = (
err: Error | null | undefined,
msg: { code: string | number; data: string }
) => {
if (err) { if (err) {
if (this._state === SMTPState.NOTCONNECTED && !this.sock) { if (this._state === SMTPState.NOTCONNECTED && !this.sock) {
return; return;
@ -331,9 +292,9 @@ class SMTP extends EventEmitter {
this.quit(() => { this.quit(() => {
caller( caller(
callback, callback,
SMTPError( makeSMTPError(
'bad response on connection', 'bad response on connection',
SMTPError.BADRESPONSE, SMTPErrorStates.BADRESPONSE,
err, err,
msg.data msg.data
) )
@ -354,14 +315,10 @@ class SMTP extends EventEmitter {
); );
} else { } else {
this.sock = new Socket(); this.sock = new Socket();
this.sock.connect( this.sock.connect(this.port, this.host, connectedErrBack);
this.port,
this.host,
connectedErrBack
);
} }
this.monitor = SMTPResponse.monitor(this.sock, this.timeout, () => this.monitor = new SMTPResponse(this.sock, this.timeout, () =>
this.close(true) this.close(true)
); );
this.sock.once('response', response); this.sock.once('response', response);
@ -369,11 +326,12 @@ class SMTP extends EventEmitter {
} }
/** /**
* @public
* @param {string} str the string to send * @param {string} str the string to send
* @param {*} callback function to call after response * @param {*} callback function to call after response
* @returns {void} * @returns {void}
*/ */
send(str, callback) { public send(str: string, callback: (...args: any[]) => void) {
if (this.sock && this._state === SMTPState.CONNECTED) { if (this.sock && this._state === SMTPState.CONNECTED) {
this.log(str); this.log(str);
@ -390,25 +348,36 @@ class SMTP extends EventEmitter {
this.close(true); this.close(true);
caller( caller(
callback, callback,
SMTPError('no connection has been established', SMTPError.NOCONNECTION) makeSMTPError(
'no connection has been established',
SMTPErrorStates.NOCONNECTION
)
); );
} }
} }
/** /**
* @public
* @param {string} cmd command to issue * @param {string} cmd command to issue
* @param {function(...*): void} callback function to call after response * @param {function(...*): void} callback function to call after response
* @param {(number[] | number)} [codes=[250]] array codes * @param {(number[] | number)} [codes=[250]] array codes
* @returns {void} * @returns {void}
*/ */
command(cmd, callback, codes = [250]) { public command(
cmd: string,
callback: (...rest: any[]) => void,
codes: number[] | number = [250]
) {
const codesArray = Array.isArray(codes) const codesArray = Array.isArray(codes)
? codes ? codes
: typeof codes === 'number' : typeof codes === 'number'
? [codes] ? [codes]
: [250]; : [250];
const response = (err, msg) => { const response = (
err: Error | null | undefined,
msg: { code: string | number; data: string; message: string }
) => {
if (err) { if (err) {
caller(callback, err); caller(callback, err);
} else { } else {
@ -421,7 +390,12 @@ class SMTP extends EventEmitter {
}'${suffix}`; }'${suffix}`;
caller( caller(
callback, callback,
SMTPError(errorMessage, SMTPError.BADRESPONSE, null, msg.data) makeSMTPError(
errorMessage,
SMTPErrorStates.BADRESPONSE,
null,
msg.data
)
); );
} }
} }
@ -431,7 +405,8 @@ class SMTP extends EventEmitter {
} }
/** /**
* SMTP 'helo' command. * @public
* @description SMTP 'helo' command.
* *
* Hostname to send for self command defaults to the FQDN of the local * Hostname to send for self command defaults to the FQDN of the local
* host. * host.
@ -440,7 +415,7 @@ class SMTP extends EventEmitter {
* @param {string} domain the domain to associate with the 'helo' request * @param {string} domain the domain to associate with the 'helo' request
* @returns {void} * @returns {void}
*/ */
helo(callback, domain) { public helo(callback: (...rest: any[]) => void, domain?: string) {
this.command(`helo ${domain || this.domain}`, (err, data) => { this.command(`helo ${domain || this.domain}`, (err, data) => {
if (err) { if (err) {
caller(callback, err); caller(callback, err);
@ -452,11 +427,16 @@ class SMTP extends EventEmitter {
} }
/** /**
* @public
* @param {function(...*): void} callback function to call after response * @param {function(...*): void} callback function to call after response
* @returns {void} * @returns {void}
*/ */
starttls(callback) { public starttls(callback: (...rest: any[]) => void) {
const response = (err, msg) => { const response = (err: Error, msg: { data: unknown }) => {
if (this.sock == null) {
throw new Error('null socket');
}
if (err) { if (err) {
err.message += ' while establishing a starttls session'; err.message += ' while establishing a starttls session';
caller(callback, err); caller(callback, err);
@ -466,7 +446,7 @@ class SMTP extends EventEmitter {
); );
const secureSocket = new TLSSocket(this.sock, { secureContext }); const secureSocket = new TLSSocket(this.sock, { secureContext });
secureSocket.on('error', err => { secureSocket.on('error', (err: Error) => {
this.close(true); this.close(true);
caller(callback, err); caller(callback, err);
}); });
@ -474,7 +454,7 @@ class SMTP extends EventEmitter {
this._secure = true; this._secure = true;
this.sock = secureSocket; this.sock = secureSocket;
SMTPResponse.monitor(this.sock, this.timeout, () => this.close(true)); new SMTPResponse(this.sock, this.timeout, () => this.close(true));
caller(callback, msg.data); caller(callback, msg.data);
} }
}; };
@ -483,15 +463,16 @@ class SMTP extends EventEmitter {
} }
/** /**
* @public
* @param {string} data the string to parse for features * @param {string} data the string to parse for features
* @returns {void} * @returns {void}
*/ */
parse_smtp_features(data) { public parse_smtp_features(data: string) {
// According to RFC1869 some (badly written) // According to RFC1869 some (badly written)
// MTA's will disconnect on an ehlo. Toss an exception if // MTA's will disconnect on an ehlo. Toss an exception if
// that happens -ddm // that happens -ddm
data.split('\n').forEach(ext => { data.split('\n').forEach((ext) => {
const parse = ext.match(/^(?:\d+[-=]?)\s*?([^\s]+)(?:\s+(.*)\s*?)?$/); const parse = ext.match(/^(?:\d+[-=]?)\s*?([^\s]+)(?:\s+(.*)\s*?)?$/);
// To be able to communicate with as many SMTP servers as possible, // To be able to communicate with as many SMTP servers as possible,
@ -501,7 +482,7 @@ class SMTP extends EventEmitter {
// 2) There are some servers that only advertise the auth methods we // 2) There are some servers that only advertise the auth methods we
// support using the old style. // support using the old style.
if (parse != null) { if (parse != null && this.features != null) {
// RFC 1869 requires a space between ehlo keyword and parameters. // RFC 1869 requires a space between ehlo keyword and parameters.
// It's actually stricter, in that only spaces are allowed between // It's actually stricter, in that only spaces are allowed between
// parameters, but were not going to check for that here. Note // parameters, but were not going to check for that here. Note
@ -512,11 +493,12 @@ class SMTP extends EventEmitter {
} }
/** /**
* @public
* @param {function(...*): void} callback function to call after response * @param {function(...*): void} callback function to call after response
* @param {string} domain the domain to associate with the 'ehlo' request * @param {string} domain the domain to associate with the 'ehlo' request
* @returns {void} * @returns {void}
*/ */
ehlo(callback, domain) { public ehlo(callback: (...rest: any[]) => void, domain?: string) {
this.features = {}; this.features = {};
this.command(`ehlo ${domain || this.domain}`, (err, data) => { this.command(`ehlo ${domain || this.domain}`, (err, data) => {
if (err) { if (err) {
@ -534,106 +516,116 @@ class SMTP extends EventEmitter {
} }
/** /**
* @public
* @param {string} opt the features keyname to check * @param {string} opt the features keyname to check
* @returns {boolean} whether the extension exists * @returns {boolean} whether the extension exists
*/ */
has_extn(opt) { public has_extn(opt: string): boolean {
return this.features[opt.toLowerCase()] === undefined; return (this.features ?? {})[opt.toLowerCase()] === undefined;
} }
/** /**
* SMTP 'help' command, returns text from the server * @public
* @description SMTP 'help' command, returns text from the server
* @param {function(...*): void} callback function to call after response * @param {function(...*): void} callback function to call after response
* @param {string} domain the domain to associate with the 'help' request * @param {string} domain the domain to associate with the 'help' request
* @returns {void} * @returns {void}
*/ */
help(callback, domain) { public help(callback: (...rest: any[]) => void, domain: string) {
this.command(domain ? `help ${domain}` : 'help', callback, [211, 214]); this.command(domain ? `help ${domain}` : 'help', callback, [211, 214]);
} }
/** /**
* @public
* @param {function(...*): void} callback function to call after response * @param {function(...*): void} callback function to call after response
* @returns {void} * @returns {void}
*/ */
rset(callback) { public rset(callback: (...rest: any[]) => void) {
this.command('rset', callback); this.command('rset', callback);
} }
/** /**
* @public
* @param {function(...*): void} callback function to call after response * @param {function(...*): void} callback function to call after response
* @returns {void} * @returns {void}
*/ */
noop(callback) { public noop(callback: (...rest: any[]) => void) {
this.send('noop', callback); this.send('noop', callback);
} }
/** /**
* @public
* @param {function(...*): void} callback function to call after response * @param {function(...*): void} callback function to call after response
* @param {string} from the sender * @param {string} from the sender
* @returns {void} * @returns {void}
*/ */
mail(callback, from) { public mail(callback: (...rest: any[]) => void, from: string) {
this.command(`mail FROM:${from}`, callback); this.command(`mail FROM:${from}`, callback);
} }
/** /**
* @public
* @param {function(...*): void} callback function to call after response * @param {function(...*): void} callback function to call after response
* @param {string} to the receiver * @param {string} to the receiver
* @returns {void} * @returns {void}
*/ */
rcpt(callback, to) { public rcpt(callback: (...rest: any[]) => void, to: string) {
this.command(`RCPT TO:${to}`, callback, [250, 251]); this.command(`RCPT TO:${to}`, callback, [250, 251]);
} }
/** /**
* @public
* @param {function(...*): void} callback function to call after response * @param {function(...*): void} callback function to call after response
* @returns {void} * @returns {void}
*/ */
data(callback) { public data(callback: (...rest: any[]) => void) {
this.command('data', callback, [354]); this.command('data', callback, [354]);
} }
/** /**
* @public
* @param {function(...*): void} callback function to call after response * @param {function(...*): void} callback function to call after response
* @returns {void} * @returns {void}
*/ */
data_end(callback) { public data_end(callback: (...rest: any[]) => void) {
this.command(`${CRLF}.`, callback); this.command(`${CRLF}.`, callback);
} }
/** /**
* @public
* @param {string} data the message to send * @param {string} data the message to send
* @returns {void} * @returns {void}
*/ */
message(data) { public message(data: string) {
this.log(data); this.log(data);
this.sock.write(data); this.sock?.write(data) ?? this.log('no socket to write to');
} }
/** /**
* SMTP 'verify' command -- checks for address validity. * @public
* * @description SMTP 'verify' command -- checks for address validity.
* @param {string} address the address to validate * @param {string} address the address to validate
* @param {function(...*): void} callback function to call after response * @param {function(...*): void} callback function to call after response
* @returns {void} * @returns {void}
*/ */
verify(address, callback) { public verify(address: string, callback: (...rest: any[]) => void) {
this.command(`vrfy ${address}`, callback, [250, 251, 252]); this.command(`vrfy ${address}`, callback, [250, 251, 252]);
} }
/** /**
* SMTP 'expn' command -- expands a mailing list. * @public
* * @description SMTP 'expn' command -- expands a mailing list.
* @param {string} address the mailing list to expand * @param {string} address the mailing list to expand
* @param {function(...*): void} callback function to call after response * @param {function(...*): void} callback function to call after response
* @returns {void} * @returns {void}
*/ */
expn(address, callback) { public expn(address: string, callback: (...rest: any[]) => void) {
this.command(`expn ${address}`, callback); this.command(`expn ${address}`, callback);
} }
/** /**
* Calls this.ehlo() and, if an error occurs, this.helo(). * @public
* @description Calls this.ehlo() and, if an error occurs, this.helo().
* *
* If there has been no previous EHLO or HELO command self session, self * If there has been no previous EHLO or HELO command self session, self
* method tries ESMTP EHLO first. * method tries ESMTP EHLO first.
@ -642,10 +634,14 @@ class SMTP extends EventEmitter {
* @param {string} [domain] the domain to associate with the command * @param {string} [domain] the domain to associate with the command
* @returns {void} * @returns {void}
*/ */
ehlo_or_helo_if_needed(callback, domain) { public ehlo_or_helo_if_needed(
callback: (...rest: any[]) => void,
domain?: string
) {
// is this code callable...? // is this code callable...?
if (!this.features) { if (!this.features) {
const response = (err, data) => caller(callback, err, data); const response = (err: Error, data: unknown) =>
caller(callback, err, data);
this.ehlo((err, data) => { this.ehlo((err, data) => {
if (err) { if (err) {
this.helo(response, domain); this.helo(response, domain);
@ -657,6 +653,8 @@ class SMTP extends EventEmitter {
} }
/** /**
* @public
*
* Log in on an SMTP server that requires authentication. * Log in on an SMTP server that requires authentication.
* *
* If there has been no previous EHLO or HELO command self session, self * If there has been no previous EHLO or HELO command self session, self
@ -670,28 +668,33 @@ class SMTP extends EventEmitter {
* @param {{ method: string, domain: string }} [options] login options * @param {{ method: string, domain: string }} [options] login options
* @returns {void} * @returns {void}
*/ */
login(callback, user, password, options) { public login(
callback: (...rest: any[]) => void,
user?: string,
password?: string,
options: { method?: string; domain?: string } = {}
) {
const login = { const login = {
user: user ? () => user : this.user, user: user ? () => user : this.user,
password: password ? () => password : this.password, password: password ? () => password : this.password,
method: options && options.method ? options.method.toUpperCase() : '', method: options?.method?.toUpperCase() ?? '',
}; };
const domain = options && options.domain ? options.domain : this.domain; const domain = options?.domain || this.domain;
const initiate = (err, data) => { const initiate = (err: Error | null | undefined, data: unknown) => {
if (err) { if (err) {
caller(callback, err); caller(callback, err);
return; return;
} }
let method = null; let method: keyof typeof AUTH_METHODS | null = null;
/** /**
* @param {string} challenge challenge * @param {string} challenge challenge
* @returns {string} base64 cram hash * @returns {string} base64 cram hash
*/ */
const encode_cram_md5 = challenge => { const encode_cram_md5 = (challenge: string): string => {
const hmac = createHmac('md5', login.password()); const hmac = createHmac('md5', login.password());
hmac.update(Buffer.from(challenge, 'base64').toString('ascii')); hmac.update(Buffer.from(challenge, 'base64').toString('ascii'));
return Buffer.from(`${login.user()} ${hmac.digest('hex')}`).toString( return Buffer.from(`${login.user()} ${hmac.digest('hex')}`).toString(
@ -702,7 +705,7 @@ class SMTP extends EventEmitter {
/** /**
* @returns {string} base64 login/password * @returns {string} base64 login/password
*/ */
const encode_plain = () => const encode_plain = (): string =>
Buffer.from(`\u0000${login.user()}\u0000${login.password()}`).toString( Buffer.from(`\u0000${login.user()}\u0000${login.password()}`).toString(
'base64' 'base64'
); );
@ -711,7 +714,7 @@ class SMTP extends EventEmitter {
* @see https://developers.google.com/gmail/xoauth2_protocol * @see https://developers.google.com/gmail/xoauth2_protocol
* @returns {string} base64 xoauth2 auth token * @returns {string} base64 xoauth2 auth token
*/ */
const encode_xoauth2 = () => const encode_xoauth2 = (): string =>
Buffer.from( Buffer.from(
`user=${login.user()}\u0001auth=Bearer ${login.password()}\u0001\u0001` `user=${login.user()}\u0001auth=Bearer ${login.password()}\u0001\u0001`
).toString('base64'); ).toString('base64');
@ -722,10 +725,8 @@ class SMTP extends EventEmitter {
const preferred = this.authentication; const preferred = this.authentication;
let auth = ''; let auth = '';
if (this.features && this.features.auth) { if (typeof this.features?.auth === 'string') {
if (typeof this.features.auth === 'string') { auth = this.features.auth;
auth = this.features.auth;
}
} }
for (let i = 0; i < preferred.length; i++) { for (let i = 0; i < preferred.length; i++) {
@ -742,12 +743,17 @@ class SMTP extends EventEmitter {
* @param {*} data data * @param {*} data data
* @returns {void} * @returns {void}
*/ */
const failed = (err, data) => { const failed = (err: Error, data: unknown) => {
this.loggedin = false; this.loggedin = false;
this.close(); // if auth is bad, close the connection, it won't get better by itself this.close(); // if auth is bad, close the connection, it won't get better by itself
caller( caller(
callback, callback,
SMTPError('authorization.failed', SMTPError.AUTHFAILED, err, data) makeSMTPError(
'authorization.failed',
SMTPErrorStates.AUTHFAILED,
err,
data
)
); );
}; };
@ -756,7 +762,7 @@ class SMTP extends EventEmitter {
* @param {*} data data * @param {*} data data
* @returns {void} * @returns {void}
*/ */
const response = (err, data) => { const response = (err: Error | null | undefined, data: unknown) => {
if (err) { if (err) {
failed(err, data); failed(err, data);
} else { } else {
@ -771,11 +777,15 @@ class SMTP extends EventEmitter {
* @param {string} msg msg * @param {string} msg msg
* @returns {void} * @returns {void}
*/ */
const attempt = (err, data, msg) => { const attempt = (
err: Error | null | undefined,
data: unknown,
msg: string
) => {
if (err) { if (err) {
failed(err, data); failed(err, data);
} else { } else {
if (method === AUTH_METHODS.CRAM_MD5) { if (method === AUTH_METHODS['CRAM-MD5']) {
this.command(encode_cram_md5(msg), response, [235, 503]); this.command(encode_cram_md5(msg), response, [235, 503]);
} else if (method === AUTH_METHODS.LOGIN) { } else if (method === AUTH_METHODS.LOGIN) {
this.command( this.command(
@ -793,7 +803,7 @@ class SMTP extends EventEmitter {
* @param {string} msg msg * @param {string} msg msg
* @returns {void} * @returns {void}
*/ */
const attempt_user = (err, data, msg) => { const attempt_user = (err: Error, data: unknown) => {
if (err) { if (err) {
failed(err, data); failed(err, data);
} else { } else {
@ -808,8 +818,8 @@ class SMTP extends EventEmitter {
}; };
switch (method) { switch (method) {
case AUTH_METHODS.CRAM_MD5: case AUTH_METHODS['CRAM-MD5']:
this.command(`AUTH ${AUTH_METHODS.CRAM_MD5}`, attempt, [334]); this.command(`AUTH ${AUTH_METHODS['CRAM-MD5']}`, attempt, [334]);
break; break;
case AUTH_METHODS.LOGIN: case AUTH_METHODS.LOGIN:
this.command(`AUTH ${AUTH_METHODS.LOGIN}`, attempt_user, [334]); this.command(`AUTH ${AUTH_METHODS.LOGIN}`, attempt_user, [334]);
@ -830,7 +840,12 @@ class SMTP extends EventEmitter {
break; break;
default: default:
const msg = 'no form of authorization supported'; const msg = 'no form of authorization supported';
const err = SMTPError(msg, SMTPError.AUTHNOTSUPPORTED, null, data); const err = makeSMTPError(
msg,
SMTPErrorStates.AUTHNOTSUPPORTED,
null,
data
);
caller(callback, err); caller(callback, err);
break; break;
} }
@ -840,10 +855,11 @@ class SMTP extends EventEmitter {
} }
/** /**
* @public
* @param {boolean} [force=false] whether or not to force destroy the connection * @param {boolean} [force=false] whether or not to force destroy the connection
* @returns {void} * @returns {void}
*/ */
close(force = false) { public close(force = false) {
if (this.sock) { if (this.sock) {
if (force) { if (force) {
this.log('smtp connection destroyed!'); this.log('smtp connection destroyed!');
@ -867,10 +883,11 @@ class SMTP extends EventEmitter {
} }
/** /**
* @public
* @param {function(...*): void} [callback] function to call after response * @param {function(...*): void} [callback] function to call after response
* @returns {void} * @returns {void}
*/ */
quit(callback) { public quit(callback?: (...rest: any[]) => void) {
this.command( this.command(
'quit', 'quit',
(err, data) => { (err, data) => {
@ -881,8 +898,3 @@ class SMTP extends EventEmitter {
); );
} }
} }
exports.SMTP = SMTP;
exports.state = SMTPState;
exports.authentication = AUTH_METHODS;
exports.DEFAULT_TIMEOUT = TIMEOUT;

12
smtp/typings.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
/* eslint-disable no-var */
declare module 'addressparser' {
var addressparser: (address?: string) => { name: string; address: string }[];
export = addressparser;
}
declare module 'emailjs-mime-codec' {
var codec: {
mimeWordEncode: (word?: string) => string;
};
export = codec;
}

View File

@ -1,74 +0,0 @@
describe('authorize plain', function() {
const { simpleParser: parser } = require('mailparser');
const { SMTPServer: smtpServer } = require('smtp-server');
const { expect } = require('chai');
const email = require('../email');
const port = 2526;
let server = null;
let smtp = null;
const send = function(message, verify, done) {
smtp.onData = function(stream, session, callback) {
parser(stream)
.then(verify)
.then(done)
.catch(done);
stream.on('end', callback);
};
server.send(message, function(err) {
if (err) {
throw err;
}
});
};
before(function(done) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // prevent CERT_HAS_EXPIRED errors
smtp = new smtpServer({ secure: true, authMethods: ['LOGIN'] });
smtp.listen(port, function() {
smtp.onAuth = function(auth, session, callback) {
if (auth.username == 'pooh' && auth.password == 'honey') {
callback(null, { user: 'pooh' });
} else {
return callback(new Error('invalid user / pass'));
}
};
server = email.server.connect({
port: port,
user: 'pooh',
password: 'honey',
ssl: true,
});
done();
});
});
after(function(done) {
smtp.close(done);
});
it('login', function(done) {
const message = {
subject: 'this is a test TEXT message from emailjs',
from: 'piglet@gmail.com',
to: 'pooh@gmail.com',
text: "It is hard to be brave when you're only a Very Small Animal.",
};
const created = email.message.create(message);
const callback = function(mail) {
expect(mail.text).to.equal(message.text + '\n\n\n');
expect(mail.subject).to.equal(message.subject);
expect(mail.from.text).to.equal(message.from);
expect(mail.to.text).to.equal(message.to);
};
send(created, callback, done);
});
});

70
test/authplain.ts Normal file
View File

@ -0,0 +1,70 @@
import type { Readable } from 'stream';
import test from 'ava';
import mailparser from 'mailparser';
import smtp from 'smtp-server';
import { client as c, message as m } from '../email';
type UnPromisify<T> = T extends Promise<infer U> ? U : T;
const port = 2526;
const client = new c.Client({
port,
user: 'pooh',
password: 'honey',
ssl: true,
});
const server = new smtp.SMTPServer({ secure: true, authMethods: ['LOGIN'] });
const send = (
message: m.Message,
verify: (
mail: UnPromisify<ReturnType<typeof mailparser.simpleParser>>
) => void,
done: () => void
) => {
server.onData = (stream: Readable, _session, callback: () => void) => {
mailparser.simpleParser(stream).then(verify).then(done).catch(done);
stream.on('end', callback);
};
client.send(message, (err) => {
if (err) {
throw err;
}
});
};
test.before.cb((t) => {
server.listen(port, function () {
server.onAuth = function (auth, _session, callback) {
if (auth.username == 'pooh' && auth.password == 'honey') {
callback(null, { user: 'pooh' });
} else {
return callback(new Error('invalid user / pass'));
}
};
t.end();
});
});
test.after.cb((t) => server.close(t.end));
test.cb('authorize plain', (t) => {
const msg = {
subject: 'this is a test TEXT message from emailjs',
from: 'piglet@gmail.com',
to: 'pooh@gmail.com',
text: "It is hard to be brave when you're only a Very Small Animal.",
};
send(
new m.Message(msg),
(mail) => {
t.is(mail.text, msg.text + '\n\n\n');
t.is(mail.subject, msg.subject);
t.is(mail.from?.text, msg.from);
t.is(mail.to?.text, msg.to);
},
t.end
);
});

View File

@ -1,73 +0,0 @@
describe('authorize ssl', function() {
const { simpleParser: parser } = require('mailparser');
const { SMTPServer: smtpServer } = require('smtp-server');
const { expect } = require('chai');
const email = require('../email');
const port = 2526;
let server = null;
let smtp = null;
const send = function(message, verify, done) {
smtp.onData = function(stream, session, callback) {
parser(stream)
.then(verify)
.then(done)
.catch(done);
stream.on('end', callback);
};
server.send(message, function(err) {
if (err) {
throw err;
}
});
};
before(function(done) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // prevent CERT_HAS_EXPIRED errors
smtp = new smtpServer({ secure: true, authMethods: ['LOGIN'] });
smtp.listen(port, function() {
smtp.onAuth = function(auth, session, callback) {
if (auth.username == 'pooh' && auth.password == 'honey') {
callback(null, { user: 'pooh' });
} else {
return callback(new Error('invalid user / pass'));
}
};
server = email.server.connect({
port: port,
user: 'pooh',
password: 'honey',
ssl: true,
});
done();
});
});
after(function(done) {
smtp.close(done);
});
it('login', function(done) {
const message = {
subject: 'this is a test TEXT message from emailjs',
from: 'pooh@gmail.com',
to: 'rabbit@gmail.com',
text: 'hello friend, i hope this message finds you well.',
};
send(
email.message.create(message),
function(mail) {
expect(mail.text).to.equal(message.text + '\n\n\n');
expect(mail.subject).to.equal(message.subject);
expect(mail.from.text).to.equal(message.from);
expect(mail.to.text).to.equal(message.to);
},
done
);
});
});

70
test/authssl.ts Normal file
View File

@ -0,0 +1,70 @@
import type { Readable } from 'stream';
import test from 'ava';
import mailparser from 'mailparser';
import smtp from 'smtp-server';
import { client as c, message as m } from '../email';
const port = 2526;
const client = new c.Client({
port,
user: 'pooh',
password: 'honey',
ssl: true,
});
const server = new smtp.SMTPServer({ secure: true, authMethods: ['LOGIN'] });
type UnPromisify<T> = T extends Promise<infer U> ? U : T;
const send = (
message: m.Message,
verify: (
mail: UnPromisify<ReturnType<typeof mailparser.simpleParser>>
) => void,
done: () => void
) => {
server.onData = (stream: Readable, _session, callback: () => void) => {
mailparser.simpleParser(stream).then(verify).then(done).catch(done);
stream.on('end', callback);
};
client.send(message, (err) => {
if (err) {
throw err;
}
});
};
test.before.cb((t) => {
server.listen(port, function () {
server.onAuth = function (auth, _session, callback) {
if (auth.username == 'pooh' && auth.password == 'honey') {
callback(null, { user: 'pooh' });
} else {
return callback(new Error('invalid user / pass'));
}
};
t.end();
});
});
test.after.cb((t) => server.close(t.end));
test.cb('authorize ssl', (t) => {
const msg = {
subject: 'this is a test TEXT message from emailjs',
from: 'pooh@gmail.com',
to: 'rabbit@gmail.com',
text: 'hello friend, i hope this message finds you well.',
};
send(
new m.Message(msg),
(mail) => {
t.is(mail.text, msg.text + '\n\n\n');
t.is(mail.subject, msg.subject);
t.is(mail.from?.text, msg.from);
t.is(mail.to?.text, msg.to);
},
t.end
);
});

View File

@ -1,41 +0,0 @@
describe('rfc2822 dates', function() {
const { expect } = require('chai');
const {
date: { getRFC2822Date, getRFC2822DateUTC },
} = require('../email');
var d_utc = dt => getRFC2822DateUTC(new Date(dt));
var d = (dt, utc = false) => getRFC2822Date(new Date(dt), utc);
it('should match standard regex', function(done) {
// RFC 2822 regex: For details see https://tools.ietf.org/html/rfc2822#section-3.3
// thanks to moment.js for the listing: https://github.com/moment/moment/blob/a831fc7e2694281ce31e4f090bbcf90a690f0277/src/lib/create/from-string.js#L101
var rfc2822re = /^(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun),?\s)?(\d{1,2})\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s(\d{2,4})\s(\d\d):(\d\d)(?::(\d\d))?\s(?:(UT|GMT|[ECMP][SD]T)|([Zz])|([+-]\d{4}))$/;
expect(d(0)).to.match(rfc2822re);
expect(d(329629726785)).to.match(rfc2822re);
expect(d(729629726785)).to.match(rfc2822re);
expect(d(1129629726785)).to.match(rfc2822re);
expect(d(1529629726785)).to.match(rfc2822re);
done();
});
it('should produce proper UTC dates', function(done) {
expect(d_utc(0)).to.equal('Thu, 01 Jan 1970 00:00:00 +0000');
expect(d_utc(0)).to.equal(d(0, true));
expect(d_utc(329629726785)).to.equal('Thu, 12 Jun 1980 03:48:46 +0000');
expect(d_utc(329629726785)).to.equal(d(329629726785, true));
expect(d_utc(729629726785)).to.equal('Sat, 13 Feb 1993 18:55:26 +0000');
expect(d_utc(729629726785)).to.equal(d(729629726785, true));
expect(d_utc(1129629726785)).to.equal('Tue, 18 Oct 2005 10:02:06 +0000');
expect(d_utc(1129629726785)).to.equal(d(1129629726785, true));
expect(d_utc(1529629726785)).to.equal('Fri, 22 Jun 2018 01:08:46 +0000');
expect(d_utc(1529629726785)).to.equal(d(1529629726785, true));
done();
});
});

35
test/date.ts Normal file
View File

@ -0,0 +1,35 @@
import test from 'ava';
import { date as d } from '../email';
const { getRFC2822Date, getRFC2822DateUTC } = d;
const toD_utc = (dt: number) => getRFC2822DateUTC(new Date(dt));
const toD = (dt: number, utc = false) => getRFC2822Date(new Date(dt), utc);
test('rfc2822 non-UTC', async (t) => {
// RFC 2822 regex: For details see https://tools.ietf.org/html/rfc2822#section-3.3
// thanks to moment.js for the listing: https://github.com/moment/moment/blob/a831fc7e2694281ce31e4f090bbcf90a690f0277/src/lib/create/from-string.js#L101
const rfc2822re = /^(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun),?\s)?(\d{1,2})\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s(\d{2,4})\s(\d\d):(\d\d)(?::(\d\d))?\s(?:(UT|GMT|[ECMP][SD]T)|([Zz])|([+-]\d{4}))$/;
t.regex(toD(0), rfc2822re);
t.regex(toD(329629726785), rfc2822re);
t.regex(toD(729629726785), rfc2822re);
t.regex(toD(1129629726785), rfc2822re);
t.regex(toD(1529629726785), rfc2822re);
});
test('rfc2822 UTC', async (t) => {
t.is(toD_utc(0), 'Thu, 01 Jan 1970 00:00:00 +0000');
t.is(toD_utc(0), toD(0, true));
t.is(toD_utc(329629726785), 'Thu, 12 Jun 1980 03:48:46 +0000');
t.is(toD_utc(329629726785), toD(329629726785, true));
t.is(toD_utc(729629726785), 'Sat, 13 Feb 1993 18:55:26 +0000');
t.is(toD_utc(729629726785), toD(729629726785, true));
t.is(toD_utc(1129629726785), 'Tue, 18 Oct 2005 10:02:06 +0000');
t.is(toD_utc(1129629726785), toD(1129629726785, true));
t.is(toD_utc(1529629726785), 'Fri, 22 Jun 2018 01:08:46 +0000');
t.is(toD_utc(1529629726785), toD(1529629726785, true));
});

View File

@ -1,461 +0,0 @@
describe('messages', function() {
const { simpleParser: parser } = require('mailparser');
const { SMTPServer: smtpServer } = require('smtp-server');
const { expect } = require('chai');
const fs = require('fs');
const path = require('path');
const email = require('../email');
const port = 2526;
let server = null;
let smtp = null;
const send = function(message, verify, done) {
smtp.onData = function(stream, session, callback) {
//stream.pipe(process.stdout);
parser(stream)
.then(verify)
.then(done)
.catch(done);
stream.on('end', callback);
};
server.send(message, function(err) {
if (err) {
throw err;
}
});
};
before(function(done) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; // prevent CERT_HAS_EXPIRED errors
smtp = new smtpServer({ secure: true, authMethods: ['LOGIN'] });
smtp.listen(port, function() {
smtp.onAuth = function(auth, session, callback) {
if (auth.username == 'pooh' && auth.password == 'honey') {
callback(null, { user: 'pooh' });
} else {
return callback(new Error('invalid user / pass'));
}
};
server = email.server.connect({
port: port,
user: 'pooh',
password: 'honey',
ssl: true,
});
done();
});
});
after(function(done) {
smtp.close(done);
});
it('simple text message', function(done) {
var message = {
subject: 'this is a test TEXT message from emailjs',
from: 'zelda@gmail.com',
to: 'gannon@gmail.com',
text: 'hello friend, i hope this message finds you well.',
'message-id': 'this is a special id',
};
send(
email.message.create(message),
function(mail) {
expect(mail.text).to.equal(message.text + '\n\n\n');
expect(mail.subject).to.equal(message.subject);
expect(mail.from.text).to.equal(message.from);
expect(mail.to.text).to.equal(message.to);
expect(mail.messageId).to.equal('<' + message['message-id'] + '>');
},
done
);
});
it('null text', function(done) {
send(
{
subject: 'this is a test TEXT message from emailjs',
from: 'zelda@gmail.com',
to: 'gannon@gmail.com',
text: null,
'message-id': 'this is a special id',
},
function(mail) {
expect(mail.text).to.equal('\n\n\n');
},
done
);
});
it('empty text', function(done) {
send(
{
subject: 'this is a test TEXT message from emailjs',
from: 'zelda@gmail.com',
to: 'gannon@gmail.com',
text: '',
'message-id': 'this is a special id',
},
function(mail) {
expect(mail.text).to.equal('\n\n\n');
},
done
);
});
it('simple unicode text message', function(done) {
var message = {
subject: 'this ✓ is a test ✓ TEXT message from emailjs',
from: 'zelda✓ <zelda@gmail.com>',
to: 'gannon✓ <gannon@gmail.com>',
text: 'hello ✓ friend, i hope this message finds you well.',
};
send(
email.message.create(message),
function(mail) {
expect(mail.text).to.equal(message.text + '\n\n\n');
expect(mail.subject).to.equal(message.subject);
expect(mail.from.text).to.equal(message.from);
expect(mail.to.text).to.equal(message.to);
},
done
);
});
it('very large text message', function(done) {
this.timeout(20000);
// thanks to jart+loberstech for this one!
var message = {
subject: 'this is a test TEXT message from emailjs',
from: 'ninjas@gmail.com',
to: 'pirates@gmail.com',
text: fs.readFileSync(
path.join(__dirname, 'attachments/smtp.txt'),
'utf-8'
),
};
send(
email.message.create(message),
function(mail) {
expect(mail.text).to.equal(message.text.replace(/\r/g, '') + '\n\n\n');
expect(mail.subject).to.equal(message.subject);
expect(mail.from.text).to.equal(message.from);
expect(mail.to.text).to.equal(message.to);
},
done
);
});
it('very large text data', function(done) {
this.timeout(10000);
var text =
'<html><body><pre>' +
fs.readFileSync(path.join(__dirname, 'attachments/smtp.txt'), 'utf-8') +
'</pre></body></html>';
var message = {
subject: 'this is a test TEXT+DATA message from emailjs',
from: 'lobsters@gmail.com',
to: 'lizards@gmail.com',
text:
'hello friend if you are seeing this, you can not view html emails. it is attached inline.',
attachment: { data: text, alternative: true },
};
send(
message,
function(mail) {
expect(mail.html).to.equal(text.replace(/\r/g, ''));
expect(mail.text).to.equal(message.text + '\n');
expect(mail.subject).to.equal(message.subject);
expect(mail.from.text).to.equal(message.from);
expect(mail.to.text).to.equal(message.to);
},
done
);
});
it('html data', function(done) {
var html = fs.readFileSync(
path.join(__dirname, 'attachments/smtp.html'),
'utf-8'
);
var message = {
subject: 'this is a test TEXT+HTML+DATA message from emailjs',
from: 'obama@gmail.com',
to: 'mitt@gmail.com',
attachment: { data: html, alternative: true },
};
send(
message,
function(mail) {
expect(mail.html).to.equal(html.replace(/\r/g, ''));
expect(mail.text).to.equal('\n');
expect(mail.subject).to.equal(message.subject);
expect(mail.from.text).to.equal(message.from);
expect(mail.to.text).to.equal(message.to);
},
done
);
});
it('html file', function(done) {
var html = fs.readFileSync(
path.join(__dirname, 'attachments/smtp.html'),
'utf-8'
);
var headers = {
subject: 'this is a test TEXT+HTML+FILE message from emailjs',
from: 'thomas@gmail.com',
to: 'nikolas@gmail.com',
attachment: {
path: path.join(__dirname, 'attachments/smtp.html'),
alternative: true,
},
};
send(
headers,
function(mail) {
expect(mail.html).to.equal(html.replace(/\r/g, ''));
expect(mail.text).to.equal('\n');
expect(mail.subject).to.equal(headers.subject);
expect(mail.from.text).to.equal(headers.from);
expect(mail.to.text).to.equal(headers.to);
},
done
);
});
it('html with image embed', function(done) {
var html = fs.readFileSync(
path.join(__dirname, 'attachments/smtp2.html'),
'utf-8'
);
var image = fs.readFileSync(path.join(__dirname, 'attachments/smtp.gif'));
var headers = {
subject: 'this is a test TEXT+HTML+IMAGE message from emailjs',
from: 'ninja@gmail.com',
to: 'pirate@gmail.com',
attachment: {
path: path.join(__dirname, 'attachments/smtp2.html'),
alternative: true,
related: [
{
path: path.join(__dirname, 'attachments/smtp.gif'),
type: 'image/gif',
name: 'smtp-diagram.gif',
headers: { 'Content-ID': '<smtp-diagram@local>' },
},
],
},
};
send(
headers,
function(mail) {
expect(mail.attachments[0].content.toString('base64')).to.equal(
image.toString('base64')
);
expect(mail.html).to.equal(html.replace(/\r/g, ''));
expect(mail.text).to.equal('\n');
expect(mail.subject).to.equal(headers.subject);
expect(mail.from.text).to.equal(headers.from);
expect(mail.to.text).to.equal(headers.to);
},
done
);
});
it('html data and attachment', function(done) {
var html = fs.readFileSync(
path.join(__dirname, 'attachments/smtp.html'),
'utf-8'
);
var headers = {
subject: 'this is a test TEXT+HTML+FILE message from emailjs',
from: 'thomas@gmail.com',
to: 'nikolas@gmail.com',
attachment: [
{
path: path.join(__dirname, 'attachments/smtp.html'),
alternative: true,
},
{ path: path.join(__dirname, 'attachments/smtp.gif') },
],
};
send(
headers,
function(mail) {
expect(mail.html).to.equal(html.replace(/\r/g, ''));
expect(mail.text).to.equal('\n');
expect(mail.subject).to.equal(headers.subject);
expect(mail.from.text).to.equal(headers.from);
expect(mail.to.text).to.equal(headers.to);
},
done
);
});
it('attachment', function(done) {
var pdf = fs.readFileSync(path.join(__dirname, 'attachments/smtp.pdf'));
var headers = {
subject: 'this is a test TEXT+ATTACHMENT message from emailjs',
from: 'washing@gmail.com',
to: 'lincoln@gmail.com',
text: 'hello friend, i hope this message and pdf finds you well.',
attachment: {
path: path.join(__dirname, 'attachments/smtp.pdf'),
type: 'application/pdf',
name: 'smtp-info.pdf',
},
};
send(
headers,
function(mail) {
expect(mail.attachments[0].content.toString('base64')).to.equal(
pdf.toString('base64')
);
expect(mail.text).to.equal(headers.text + '\n');
expect(mail.subject).to.equal(headers.subject);
expect(mail.from.text).to.equal(headers.from);
expect(mail.to.text).to.equal(headers.to);
},
done
);
});
it('attachment sent with unicode filename', function(done) {
var pdf = fs.readFileSync(path.join(__dirname, 'attachments/smtp.pdf'));
var headers = {
subject: 'this is a test TEXT+ATTACHMENT message from emailjs',
from: 'washing@gmail.com',
to: 'lincoln@gmail.com',
text: 'hello friend, i hope this message and pdf finds you well.',
attachment: {
path: path.join(__dirname, 'attachments/smtp.pdf'),
type: 'application/pdf',
name: 'smtp-✓-info.pdf',
},
};
send(
headers,
function(mail) {
expect(mail.attachments[0].content.toString('base64')).to.equal(
pdf.toString('base64')
);
expect(mail.attachments[0].filename).to.equal('smtp-✓-info.pdf');
expect(mail.text).to.equal(headers.text + '\n');
expect(mail.subject).to.equal(headers.subject);
expect(mail.from.text).to.equal(headers.from);
expect(mail.to.text).to.equal(headers.to);
},
done
);
});
it('attachments', function(done) {
this.timeout(4000); /** simpleParser takes a while on macOS */
var pdf = fs.readFileSync(path.join(__dirname, 'attachments/smtp.pdf'));
var tar = fs.readFileSync(
path.join(__dirname, 'attachments/postfix-2.8.7.tar.gz')
);
var headers = {
subject: 'this is a test TEXT+2+ATTACHMENTS message from emailjs',
from: 'sergey@gmail.com',
to: 'jobs@gmail.com',
text: 'hello friend, i hope this message and attachments finds you well.',
attachment: [
{
path: path.join(__dirname, 'attachments/smtp.pdf'),
type: 'application/pdf',
name: 'smtp-info.pdf',
},
{
path: path.join(__dirname, 'attachments/postfix-2.8.7.tar.gz'),
type: 'application/tar-gz',
name: 'postfix.source.2.8.7.tar.gz',
},
],
};
send(
headers,
function(mail) {
expect(mail.attachments[0].content.toString('base64')).to.equal(
pdf.toString('base64')
);
expect(mail.attachments[1].content.toString('base64')).to.equal(
tar.toString('base64')
);
expect(mail.text).to.equal(headers.text + '\n');
expect(mail.subject).to.equal(headers.subject);
expect(mail.from.text).to.equal(headers.from);
expect(mail.to.text).to.equal(headers.to);
},
done
);
});
it('streams', function(done) {
this.timeout(4000); /** simpleParser takes a while on macOS */
var pdf = fs.readFileSync(path.join(__dirname, 'attachments/smtp.pdf'));
var tar = fs.readFileSync(
path.join(__dirname, 'attachments/postfix-2.8.7.tar.gz')
);
var stream = fs.createReadStream(
path.join(__dirname, 'attachments/smtp.pdf')
);
var stream2 = fs.createReadStream(
path.join(__dirname, 'attachments/postfix-2.8.7.tar.gz')
);
var headers = {
subject:
'this is a test TEXT+2+STREAMED+ATTACHMENTS message from emailjs',
from: 'stanford@gmail.com',
to: 'mit@gmail.com',
text:
'hello friend, i hope this message and streamed attachments finds you well.',
attachment: [
{ stream: stream, type: 'application/pdf', name: 'smtp-info.pdf' },
{
stream: stream2,
type: 'application/x-gzip',
name: 'postfix.source.2.8.7.tar.gz',
},
],
};
stream.pause();
stream2.pause();
send(
headers,
function(mail) {
expect(mail.attachments[0].content.toString('base64')).to.equal(
pdf.toString('base64')
);
expect(mail.attachments[1].content.toString('base64')).to.equal(
tar.toString('base64')
);
expect(mail.text).to.equal(headers.text + '\n');
expect(mail.subject).to.equal(headers.subject);
expect(mail.from.text).to.equal(headers.from);
expect(mail.to.text).to.equal(headers.to);
},
done
);
});
});

449
test/message.ts Normal file
View File

@ -0,0 +1,449 @@
import type { Readable } from 'stream';
import { readFileSync, createReadStream } from 'fs';
import { join } from 'path';
import test from 'ava';
import mailparser from 'mailparser';
import smtp from 'smtp-server';
import { client as c, message as m } from '../email';
const port = 2526;
const client = new c.Client({
port,
user: 'pooh',
password: 'honey',
ssl: true,
});
const server = new smtp.SMTPServer({ secure: true, authMethods: ['LOGIN'] });
type UnPromisify<T> = T extends Promise<infer U> ? U : T;
const send = (
message: m.Message,
verify: (
mail: UnPromisify<ReturnType<typeof mailparser.simpleParser>>
) => void,
done: () => void
) => {
server.onData = (stream: Readable, _session, callback: () => void) => {
mailparser
.simpleParser(stream, { skipTextLinks: true } as Record<string, unknown>)
.then(verify)
.then(done)
.catch(done);
stream.on('end', callback);
};
client.send(message, (err) => {
if (err) {
throw err;
}
});
};
test.before.cb((t) => {
server.listen(port, function () {
server.onAuth = function (auth, _session, callback) {
if (auth.username == 'pooh' && auth.password == 'honey') {
callback(null, { user: 'pooh' });
} else {
return callback(new Error('invalid user / pass'));
}
};
t.end();
});
});
test.after.cb((t) => server.close(t.end));
test.cb('simple text message', (t) => {
const msg = {
subject: 'this is a test TEXT message from emailjs',
from: 'zelda@gmail.com',
to: 'gannon@gmail.com',
text: 'hello friend, i hope this message finds you well.',
'message-id': 'this is a special id',
};
send(
new m.Message(msg),
(mail) => {
t.is(mail.text, msg.text + '\n\n\n');
t.is(mail.subject, msg.subject);
t.is(mail.from?.text, msg.from);
t.is(mail.to?.text, msg.to);
t.is(mail.messageId, '<' + msg['message-id'] + '>');
},
t.end
);
});
test.cb('null text message', (t) => {
const msg = {
subject: 'this is a test TEXT message from emailjs',
from: 'zelda@gmail.com',
to: 'gannon@gmail.com',
text: null,
'message-id': 'this is a special id',
};
send(
new m.Message(msg),
(mail) => {
t.is(mail.text, '\n\n\n');
},
t.end
);
});
test.cb('empty text message', (t) => {
const msg = {
subject: 'this is a test TEXT message from emailjs',
from: 'zelda@gmail.com',
to: 'gannon@gmail.com',
text: '',
'message-id': 'this is a special id',
};
send(
new m.Message(msg),
(mail) => {
t.is(mail.text, '\n\n\n');
},
t.end
);
});
test.cb('simple unicode text message', (t) => {
const msg = {
subject: 'this ✓ is a test ✓ TEXT message from emailjs',
from: 'zelda✓ <zelda@gmail.com>',
to: 'gannon✓ <gannon@gmail.com>',
text: 'hello ✓ friend, i hope this message finds you well.',
};
send(
new m.Message(msg),
(mail) => {
t.is(mail.text, msg.text + '\n\n\n');
t.is(mail.subject, msg.subject);
t.is(mail.from?.text, msg.from);
t.is(mail.to?.text, msg.to);
},
t.end
);
});
test.cb('very large text message', (t) => {
// thanks to jart+loberstech for this one!
const msg = {
subject: 'this is a test TEXT message from emailjs',
from: 'ninjas@gmail.com',
to: 'pirates@gmail.com',
text: readFileSync(join(__dirname, 'attachments/smtp.txt'), 'utf-8'),
};
send(
new m.Message(msg),
(mail) => {
t.is(mail.text, msg.text.replace(/\r/g, '') + '\n\n\n');
t.is(mail.subject, msg.subject);
t.is(mail.from?.text, msg.from);
t.is(mail.to?.text, msg.to);
},
t.end
);
});
test.cb('very large text data message', (t) => {
const text =
'<html><body><pre>' +
readFileSync(join(__dirname, 'attachments/smtp.txt'), 'utf-8') +
'</pre></body></html>';
const msg = {
subject: 'this is a test TEXT+DATA message from emailjs',
from: 'lobsters@gmail.com',
to: 'lizards@gmail.com',
text:
'hello friend if you are seeing this, you can not view html emails. it is attached inline.',
attachment: ({
data: text,
alternative: true,
} as unknown) as m.MessageAttachment,
};
send(
new m.Message(msg),
(mail) => {
t.is(mail.html, text.replace(/\r/g, ''));
t.is(mail.text, msg.text + '\n');
t.is(mail.subject, msg.subject);
t.is(mail.from?.text, msg.from);
t.is(mail.to?.text, msg.to);
},
t.end
);
});
test.cb('html data message', (t) => {
const html = readFileSync(join(__dirname, 'attachments/smtp.html'), 'utf-8');
const msg = {
subject: 'this is a test TEXT+HTML+DATA message from emailjs',
from: 'obama@gmail.com',
to: 'mitt@gmail.com',
attachment: ({
data: html,
alternative: true,
} as unknown) as m.MessageAttachment,
};
send(
new m.Message(msg),
(mail) => {
t.is(mail.html, html.replace(/\r/g, ''));
t.is(mail.text, '\n');
t.is(mail.subject, msg.subject);
t.is(mail.from?.text, msg.from);
t.is(mail.to?.text, msg.to);
},
t.end
);
});
test.cb('html file message', (t) => {
const html = readFileSync(join(__dirname, 'attachments/smtp.html'), 'utf-8');
const msg = {
subject: 'this is a test TEXT+HTML+FILE message from emailjs',
from: 'thomas@gmail.com',
to: 'nikolas@gmail.com',
attachment: ({
path: join(__dirname, 'attachments/smtp.html'),
alternative: true,
} as unknown) as m.MessageAttachment,
};
send(
new m.Message(msg),
(mail) => {
t.is(mail.html, html.replace(/\r/g, ''));
t.is(mail.text, '\n');
t.is(mail.subject, msg.subject);
t.is(mail.from?.text, msg.from);
t.is(mail.to?.text, msg.to);
},
t.end
);
});
test.cb('html with image embed message', (t) => {
const html = readFileSync(join(__dirname, 'attachments/smtp2.html'), 'utf-8');
const image = readFileSync(join(__dirname, 'attachments/smtp.gif'));
const msg = {
subject: 'this is a test TEXT+HTML+IMAGE message from emailjs',
from: 'ninja@gmail.com',
to: 'pirate@gmail.com',
attachment: ({
path: join(__dirname, 'attachments/smtp2.html'),
alternative: true,
related: [
{
path: join(__dirname, 'attachments/smtp.gif'),
type: 'image/gif',
name: 'smtp-diagram.gif',
headers: { 'Content-ID': '<smtp-diagram@local>' },
},
],
} as unknown) as m.MessageAttachment,
};
send(
new m.Message(msg),
(mail) => {
t.is(
mail.attachments[0].content.toString('base64'),
image.toString('base64')
);
t.is(mail.html, html.replace(/\r/g, ''));
t.is(mail.text, '\n');
t.is(mail.subject, msg.subject);
t.is(mail.from?.text, msg.from);
t.is(mail.to?.text, msg.to);
},
t.end
);
});
test.cb('html data and attachment message', (t) => {
const html = readFileSync(join(__dirname, 'attachments/smtp.html'), 'utf-8');
const msg = {
subject: 'this is a test TEXT+HTML+FILE message from emailjs',
from: 'thomas@gmail.com',
to: 'nikolas@gmail.com',
attachment: [
{ path: join(__dirname, 'attachments/smtp.html'), alternative: true },
{ path: join(__dirname, 'attachments/smtp.gif') },
] as m.MessageAttachment[],
};
send(
new m.Message(msg),
(mail) => {
t.is(mail.html, html.replace(/\r/g, ''));
t.is(mail.text, '\n');
t.is(mail.subject, msg.subject);
t.is(mail.from?.text, msg.from);
t.is(mail.to?.text, msg.to);
},
t.end
);
});
test.cb('attachment message', (t) => {
const pdf = readFileSync(join(__dirname, 'attachments/smtp.pdf'));
const msg = {
subject: 'this is a test TEXT+ATTACHMENT message from emailjs',
from: 'washing@gmail.com',
to: 'lincoln@gmail.com',
text: 'hello friend, i hope this message and pdf finds you well.',
attachment: {
path: join(__dirname, 'attachments/smtp.pdf'),
type: 'application/pdf',
name: 'smtp-info.pdf',
} as m.MessageAttachment,
};
send(
new m.Message(msg),
(mail) => {
t.is(
mail.attachments[0].content.toString('base64'),
pdf.toString('base64')
);
t.is(mail.text, msg.text + '\n');
t.is(mail.subject, msg.subject);
t.is(mail.from?.text, msg.from);
t.is(mail.to?.text, msg.to);
},
t.end
);
});
test.cb('attachment sent with unicode filename message', (t) => {
const pdf = readFileSync(join(__dirname, 'attachments/smtp.pdf'));
const msg = {
subject: 'this is a test TEXT+ATTACHMENT message from emailjs',
from: 'washing@gmail.com',
to: 'lincoln@gmail.com',
text: 'hello friend, i hope this message and pdf finds you well.',
attachment: {
path: join(__dirname, 'attachments/smtp.pdf'),
type: 'application/pdf',
name: 'smtp-✓-info.pdf',
} as m.MessageAttachment,
};
send(
new m.Message(msg),
(mail) => {
t.is(
mail.attachments[0].content.toString('base64'),
pdf.toString('base64')
);
t.is(mail.attachments[0].filename, 'smtp-✓-info.pdf');
t.is(mail.text, msg.text + '\n');
t.is(mail.subject, msg.subject);
t.is(mail.from?.text, msg.from);
t.is(mail.to?.text, msg.to);
},
t.end
);
});
test.cb('attachments message', (t) => {
const pdf = readFileSync(join(__dirname, 'attachments/smtp.pdf'));
const tar = readFileSync(join(__dirname, 'attachments/postfix-2.8.7.tar.gz'));
const msg = {
subject: 'this is a test TEXT+2+ATTACHMENTS message from emailjs',
from: 'sergey@gmail.com',
to: 'jobs@gmail.com',
text: 'hello friend, i hope this message and attachments finds you well.',
attachment: [
{
path: join(__dirname, 'attachments/smtp.pdf'),
type: 'application/pdf',
name: 'smtp-info.pdf',
},
{
path: join(__dirname, 'attachments/postfix-2.8.7.tar.gz'),
type: 'application/tar-gz',
name: 'postfix.source.2.8.7.tar.gz',
},
] as m.MessageAttachment[],
};
send(
new m.Message(msg),
(mail) => {
t.is(
mail.attachments[0].content.toString('base64'),
pdf.toString('base64')
);
t.is(
mail.attachments[1].content.toString('base64'),
tar.toString('base64')
);
t.is(mail.text, msg.text + '\n');
t.is(mail.subject, msg.subject);
t.is(mail.from?.text, msg.from);
t.is(mail.to?.text, msg.to);
},
t.end
);
});
test.cb('streams message', (t) => {
const pdf = readFileSync(join(__dirname, 'attachments/smtp.pdf'));
const tar = readFileSync(join(__dirname, 'attachments/postfix-2.8.7.tar.gz'));
const stream = createReadStream(join(__dirname, 'attachments/smtp.pdf'));
const stream2 = createReadStream(
join(__dirname, 'attachments/postfix-2.8.7.tar.gz')
);
const msg = {
subject: 'this is a test TEXT+2+STREAMED+ATTACHMENTS message from emailjs',
from: 'stanford@gmail.com',
to: 'mit@gmail.com',
text:
'hello friend, i hope this message and streamed attachments finds you well.',
attachment: ([
{ stream, type: 'application/pdf', name: 'smtp-info.pdf' },
{
stream: stream2,
type: 'application/x-gzip',
name: 'postfix.source.2.8.7.tar.gz',
},
] as unknown) as m.MessageAttachment[],
};
stream.pause();
stream2.pause();
send(
new m.Message(msg),
(mail) => {
t.is(
mail.attachments[0].content.toString('base64'),
pdf.toString('base64')
);
t.is(
mail.attachments[1].content.toString('base64'),
tar.toString('base64')
);
t.is(mail.text, msg.text + '\n');
t.is(mail.subject, msg.subject);
t.is(mail.from?.text, msg.from);
t.is(mail.to?.text, msg.to);
},
t.end
);
});

View File

@ -1,63 +0,0 @@
const assert = require('assert');
describe('Connect to wrong email server', function() {
const emailModulePath = require.resolve('../email.js');
/**
* @type {typeof import('../email.js')}
*/
let email = null;
beforeEach(function() {
if (require.cache[emailModulePath]) {
delete require.cache[emailModulePath];
}
email = require(emailModulePath);
});
it('Should not call callback multiple times with wrong server configuration', function(done) {
this.timeout(5000);
const server = email.server.connect({ host: 'bar.baz' });
server.send(
{
from: 'foo@bar.baz',
to: 'foo@bar.baz',
subject: 'hello world',
text: 'hello world',
},
function(err) {
assert.notEqual(err, null);
done();
}
);
});
it('should have a default timeout', function(done) {
const connectionOptions = {
user: 'username',
password: 'password',
host: '127.0.0.1',
port: 1234,
};
const email = require(emailModulePath);
assert.strictEqual(
email.server.connect(connectionOptions).smtp.timeout,
email.SMTP.DEFAULT_TIMEOUT
);
connectionOptions.timeout = null;
assert.strictEqual(
email.server.connect(connectionOptions).smtp.timeout,
email.SMTP.DEFAULT_TIMEOUT
);
connectionOptions.timeout = undefined;
assert.strictEqual(
email.server.connect(connectionOptions).smtp.timeout,
email.SMTP.DEFAULT_TIMEOUT
);
done();
});
});

37
test/server.ts Normal file
View File

@ -0,0 +1,37 @@
import test from 'ava';
import { client as c, message as m, smtp as s } from '../email';
test.cb(
'connecting to wrong email server should not invoke callback multiple times',
(t) => {
const client = new c.Client({ host: 'bar.baz' });
const msg = {
from: 'foo@bar.baz',
to: 'foo@bar.baz',
subject: 'hello world',
text: 'hello world',
};
client.send(new m.Message(msg), (err) => {
t.not(err, null);
t.end();
});
}
);
test('should have a default timeout', async (t) => {
const connectionOptions = {
user: 'username',
password: 'password',
host: '127.0.0.1',
port: 1234,
timeout: undefined as number | null | undefined,
};
t.is(new c.Client(connectionOptions).smtp.timeout, s.DEFAULT_TIMEOUT);
connectionOptions.timeout = null;
t.is(new c.Client(connectionOptions).smtp.timeout, s.DEFAULT_TIMEOUT);
connectionOptions.timeout = undefined;
t.is(new c.Client(connectionOptions).smtp.timeout, s.DEFAULT_TIMEOUT);
});

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "@ledge/configs/tsconfig.json",
"include": [
"*.ts",
"smtp/*.ts",
"test/*.ts"
],
"ts-node": {
"transpileOnly": true,
"compilerOptions": {
"module": "commonjs"
}
}
}

3455
yarn.lock

File diff suppressed because it is too large Load Diff