1
0
mirror of https://github.com/eleith/emailjs.git synced 2024-06-14 20:19: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": {
"es6": true,
"mocha": true,
"node": true
},
"plugins": [
"mocha"
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:prettier/recommended"
"plugin:prettier/recommended",
"plugin:@typescript-eslint/recommended"
],
"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": [
"error",
"all"
@ -30,11 +46,6 @@
"ignoreRestSiblings": true
}
],
"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"
"valid-jsdoc": "error"
}
}

View File

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

8
.gitignore vendored
View File

@ -1,7 +1,7 @@
/.vscode/
/node_modules/
/test/config.js
/npm-debug.log
.vscode
node_modules
*.log
*.swp
*.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
@ -21,119 +21,129 @@ send emails, html and attachments (files, streams and strings) from node.js to a
## EXAMPLE USAGE - text only emails
```javascript
var email = require("./path/to/emailjs/email");
var server = email.server.connect({
user: "username",
password:"password",
host: "smtp.your-email.com",
ssl: true
import { client as c } from 'emailjs';
const client = new c.Client({
user: 'user',
password: 'password',
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
server.send({
text: "i hope this works",
from: "you <username@your-email.com>",
to: "someone <someone@your-email.com>, another <another@your-email.com>",
cc: "else <else@your-email.com>",
subject: "testing emailjs"
}, function(err, message) { console.log(err || message); });
client.send({
text: 'i hope this works',
from: 'you <username@your-email.com>',
to: 'someone <someone@your-email.com>, another <another@your-email.com>',
cc: 'else <else@your-email.com>',
subject: 'testing emailjs'
}, (err, message) => {
console.log(err || message);
});
```
## EXAMPLE USAGE - html emails and attachments
```javascript
var email = require("./path/to/emailjs/email");
var server = email.server.connect({
user: "username",
password:"password",
host: "smtp.your-email.com",
ssl: true
import { client as c } from 'emailjs';
const client = new c.Client({
user: 'user',
password: 'password',
host: 'smtp.your-email.com',
ssl: true
});
var message = {
text: "i hope this works",
from: "you <username@your-email.com>",
to: "someone <someone@your-email.com>, another <another@your-email.com>",
cc: "else <else@your-email.com>",
subject: "testing emailjs",
attachment:
[
{data:"<html>i <i>hope</i> this works!</html>", alternative:true},
{path:"path/to/file.zip", type:"application/zip", name:"renamed.zip"}
]
const message = {
text: 'i hope this works',
from: 'you <username@your-email.com>',
to: 'someone <someone@your-email.com>, another <another@your-email.com>',
cc: 'else <else@your-email.com>',
subject: 'testing emailjs',
attachment: [
{ data: '<html>i <i>hope</i> this works!</html>', alternative: true },
{ 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
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
// or you can create a new server connection with 'email.server.connect'
// to asynchronously send individual emails instead of a queue
// or instead of using the built-in client you can create an instance of 'smtp.SMTPConnection'
```
## EXAMPLE USAGE - sending through hotmail/outlook
## EXAMPLE USAGE - sending through outlook
```javascript
var email = require("./path/to/emailjs/email");
var server = email.server.connect({
user: "username",
password:"password",
host: "smtp-mail.outlook.com",
tls: {ciphers: "SSLv3"}
import { client as c, message as m } from 'emailjs';
const client = new c.Client({
user: 'user',
password: 'password',
host: 'smtp-mail.outlook.com',
tls: {
ciphers: 'SSLv3'
}
});
var message = {
text: "i hope this works",
from: "you <username@outlook.com>",
to: "someone <someone@your-email.com>, another <another@your-email.com>",
cc: "else <else@your-email.com>",
subject: "testing emailjs",
attachment:
[
{data:"<html>i <i>hope</i> this works!</html>", alternative:true},
{path:"path/to/file.zip", type:"application/zip", name:"renamed.zip"}
]
};
const message = new m.Message({
text: 'i hope this works',
from: 'you <username@outlook.com>',
to: 'someone <someone@your-email.com>, another <another@your-email.com>',
cc: 'else <else@your-email.com>',
subject: 'testing emailjs',
attachment: [
{ data: '<html>i <i>hope</i> this works!</html>', alternative: true },
{ 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
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
```javascript
var email = require("./path/to/emailjs/email");
var server = email.server.connect({
user: "username",
password:"password",
host: "smtp-mail.outlook.com",
tls: {ciphers: "SSLv3"}
import { client as c, message as m } from 'emailjs';
const client = new c.Client({
user: 'user',
password: 'password',
host: 'smtp-mail.outlook.com',
tls: {
ciphers: 'SSLv3'
}
});
var message = {
text: "i hope this works",
from: "you <username@outlook.com>",
to: "someone <someone@your-email.com>, another <another@your-email.com>",
cc: "else <else@your-email.com>",
subject: "testing emailjs",
attachment:
[
{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/image.jpg", type:"image/jpg", headers:{"Content-ID":"<my-image>"}}
]
};
const message = new m.Message({
text: 'i hope this works',
from: 'you <username@outlook.com>',
to: 'someone <someone@your-email.com>, another <another@your-email.com>',
cc: 'else <else@your-email.com>',
subject: 'testing emailjs',
attachment: [
{ 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/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
server.send(message, function(err, message) { console.log(err || message); });
client.send(message, (err, message) => {
console.log(err || message);
});
```
# API
## email.server.connect(options)
## new client.Client(options)
// options is an object with the following keys
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)
}
## email.server.send(message, callback)
## client.Client#send(message, callback)
// 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
@ -158,7 +168,7 @@ server.send(message, function(err, message) { console.log(err || message); });
// callback will be executed with (err, message)
// either when message is sent or an error has occurred
## message
## new message.Message(headers)
// headers is an object ('from' and 'to' are required)
// 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
}
## email.SMTP.authentication
associative array of currently supported SMTP authentication mechanisms
## attachment
## message.Message#attach
// can be called multiple times, each adding a new attachment
// 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
}
## 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
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",
"description": "send text/html emails and attachments (files, streams and strings) from node.js to any smtp server",
"version": "2.2.0",
"author": "eleith",
"contributors": [
"izuzak",
"Hiverness",
"mscdex",
"jimmybergman",
"zackschuster"
],
"repository": {
"type": "git",
"url": "http://github.com/eleith/emailjs.git"
},
"dependencies": {
"addressparser": "^0.3.2",
"emailjs-mime-codec": "^2.0.7"
},
"devDependencies": {
"chai": "^4.1.2",
"eslint": "^5.1.0",
"eslint-config-prettier": "^2.9.0",
"eslint-plugin-mocha": "^5.1.0",
"eslint-plugin-prettier": "^2.6.2",
"mailparser": "^2.2.0",
"mocha": "^5.2.0",
"prettier": "^1.13.7",
"rollup": "^0.62.0",
"rollup-plugin-commonjs": "^9.1.3",
"rollup-plugin-node-resolve": "^3.3.0",
"smtp-server": "^3.4.6"
},
"engine": [
"node >= 6"
],
"main": "email.js",
"scripts": {
"rollup": "rollup -c rollup.config.js && npm run rollup:test",
"rollup:test": "npm run test -- --file rollup/email.bundle.test.js",
"test": "mocha"
},
"license": "MIT",
"eslintIgnore": [
"rollup.config.js",
"rollup/email.bundle.js",
"email.esm.js"
],
"prettier": {
"singleQuote": true,
"trailingComma": "es5",
"useTabs": true
}
"name": "emailjs",
"description": "send text/html emails and attachments (files, streams and strings) from node.js to any smtp server",
"version": "2.2.0",
"author": "eleith",
"contributors": [
"izuzak",
"Hiverness",
"mscdex",
"jimmybergman",
"zackschuster"
],
"repository": {
"type": "git",
"url": "http://github.com/eleith/emailjs.git"
},
"dependencies": {
"addressparser": "1.0.1",
"emailjs-mime-codec": "2.0.9"
},
"type": "module",
"devDependencies": {
"@ledge/configs": "23.0.0",
"@rollup/plugin-commonjs": "12.0.0",
"@rollup/plugin-node-resolve": "8.0.0",
"@rollup/plugin-typescript": "4.1.2",
"@types/mailparser": "2.7.3",
"@types/smtp-server": "3.5.4",
"@typescript-eslint/eslint-plugin": "3.0.1",
"@typescript-eslint/parser": "3.0.1",
"ava": "3.8.2",
"eslint": "7.1.0",
"eslint-config-prettier": "6.11.0",
"eslint-plugin-prettier": "3.1.3",
"mailparser": "2.7.7",
"prettier": "2.0.5",
"rollup": "2.10.9",
"smtp-server": "3.6.0",
"ts-node": "8.10.1",
"tslib": "2.0.0",
"typescript": "3.9.3"
},
"engine": [
"node >= 10"
],
"files": [
"email.ts",
"smtp",
"rollup"
],
"main": "./rollup/email.cjs",
"types": "./email.ts",
"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');
const { Message, create } = require('./message');
const addressparser = require('addressparser');
import addressparser from 'addressparser';
import { Message } from './message';
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
* @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
* @param {SMTPConnectionOptions} server smtp options
*/
constructor(server) {
this.smtp = new SMTP(server);
constructor(server: Partial<SMTPConnectionOptions>) {
this.smtp = new SMTPConnection(server);
//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
* @param {function(Error, MessageStack): void} callback callback
* @public
* @param {Message} msg the message to send
* @param {function(err: Error, msg: Message): void} callback sss
* @returns {void}
*/
send(msg, callback) {
/**
* @type {Message}
*/
const message =
public send(msg: Message, callback: (err: Error, msg: Message) => void) {
const message: Message | null =
msg instanceof Message
? msg
: this._canMakeMessage(msg)
? create(msg)
: null;
? new Message(msg)
: null;
if (message == null) {
callback(
new Error('message is not a valid Message instance'),
/** @type {MessageStack} */ (msg)
);
callback(new Error('message is not a valid Message instance'), msg);
return;
}
@ -91,8 +57,13 @@ class Client {
message,
to: addressparser(message.header.to),
from: addressparser(message.header.from)[0].address,
callback: (callback || function() {}).bind(this),
};
callback: (
callback ||
function () {
/* ø */
}
).bind(this),
} as MessageStack;
if (message.header.cc) {
stack.to = stack.to.concat(addressparser(message.header.cc));
@ -114,49 +85,51 @@ class Client {
this.queue.push(stack);
this._poll();
} else {
callback(new Error(why), /** @type {MessageStack} */ (msg));
callback(new Error(why), msg);
}
});
}
/**
* @private
* @protected
* @returns {void}
*/
_poll() {
clearTimeout(this.timer);
protected _poll() {
if (this.timer != null) {
clearTimeout(this.timer);
}
if (this.queue.length) {
if (this.smtp.state() == state.NOTCONNECTED) {
if (this.smtp.state() == SMTPState.NOTCONNECTED) {
this._connect(this.queue[0]);
} else if (
this.smtp.state() == state.CONNECTED &&
this.smtp.state() == SMTPState.CONNECTED &&
!this.sending &&
this.ready
) {
this._sendmail(this.queue.shift());
this._sendmail(this.queue.shift() as MessageStack);
}
}
// wait around 1 seconds in case something does come in,
// 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);
}
}
/**
* @private
* @protected
* @param {MessageStack} stack stack
* @returns {void}
*/
_connect(stack) {
protected _connect(stack: MessageStack) {
/**
* @param {Error} err callback error
* @returns {void}
*/
const connect = err => {
const connect = (err: Error) => {
if (!err) {
const begin = err => {
const begin = (err: Error) => {
if (!err) {
this.ready = true;
this._poll();
@ -188,11 +161,11 @@ class Client {
}
/**
* @private
* @protected
* @param {MessageStack} msg message stack
* @returns {boolean} can make message
*/
_canMakeMessage(msg) {
protected _canMakeMessage(msg: MessageHeaders) {
return (
msg.from &&
(msg.to || msg.cc || msg.bcc) &&
@ -201,13 +174,15 @@ class Client {
}
/**
* @private
* @protected
* @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)) {
return attachment.some(att => {
return attachment.some((att) => {
return this._isAttachmentInlinedHtml(att);
});
} else {
@ -216,11 +191,11 @@ class Client {
}
/**
* @private
* @param {*} attachment attachment
* @returns {boolean} is inlined
* @protected
* @param {MessageAttachment} attachment attachment
* @returns {boolean} whether the attachment is inlined html
*/
_isAttachmentInlinedHtml(attachment) {
protected _isAttachmentInlinedHtml(attachment: MessageAttachment) {
return (
attachment &&
(attachment.data || attachment.path) &&
@ -229,17 +204,17 @@ class Client {
}
/**
* @private
* @protected
* @param {MessageStack} stack stack
* @param {function(MessageStack): void} next next
* @returns {function(Error): void} callback
*/
_sendsmtp(stack, next) {
protected _sendsmtp(stack: MessageStack, next: (msg: MessageStack) => void) {
/**
* @param {Error} [err] error
* @returns {void}
*/
return err => {
return (err: Error) => {
if (!err && next) {
next.apply(this, [stack]);
} else {
@ -251,27 +226,27 @@ class Client {
}
/**
* @private
* @protected
* @param {MessageStack} stack stack
* @returns {void}
*/
_sendmail(stack) {
protected _sendmail(stack: MessageStack) {
const from = stack.returnPath || stack.from;
this.sending = true;
this.smtp.mail(this._sendsmtp(stack, this._sendrcpt), '<' + from + '>');
}
/**
* @private
* @protected
* @param {MessageStack} stack stack
* @returns {void}
*/
_sendrcpt(stack) {
protected _sendrcpt(stack: MessageStack) {
if (stack.to == null || typeof stack.to === 'string') {
throw new TypeError('stack.to must be array');
}
const to = stack.to.shift().address;
const to = stack.to.shift()?.address;
this.smtp.rcpt(
this._sendsmtp(stack, stack.to.length ? this._sendrcpt : this._senddata),
`<${to}>`
@ -279,23 +254,23 @@ class Client {
}
/**
* @private
* @protected
* @param {MessageStack} stack stack
* @returns {void}
*/
_senddata(stack) {
protected _senddata(stack: MessageStack) {
this.smtp.data(this._sendsmtp(stack, this._sendmessage));
}
/**
* @private
* @protected
* @param {MessageStack} stack stack
* @returns {void}
*/
_sendmessage(stack) {
protected _sendmessage(stack: MessageStack) {
const stream = stack.message.stream();
stream.on('data', data => this.smtp.message(data));
stream.on('data', (data) => this.smtp.message(data));
stream.on('end', () => {
this.smtp.data_end(
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,
// 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._senddone(err, stack);
});
}
/**
* @private
* @protected
* @param {Error} err err
* @param {MessageStack} stack stack
* @returns {void}
*/
_senddone(err, stack) {
protected _senddone(err: Error | null, stack: MessageStack) {
this.sending = false;
stack.callback(err, stack.message);
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 {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
*/
function getRFC2822Date(date = new Date(), useUtc = false) {
export function getRFC2822Date(date = new Date(), useUtc = false) {
if (useUtc) {
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)
* @returns {string} the converted date
*/
function getRFC2822DateUTC(date = new Date()) {
export function getRFC2822DateUTC(date = new Date()) {
const dates = date.toUTCString().split(' ');
dates.pop(); // remove timezone
dates.push('+0000');
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');
const { hostname } = require('os');
const { Stream } = require('stream');
const addressparser = require('addressparser');
const { mimeWordEncode } = require('emailjs-mime-codec');
const { getRFC2822Date } = require('./date');
import fs from 'fs';
import type { PathLike } from 'fs';
import { hostname } from 'os';
import { Stream } from 'stream';
import type { Duplex } from 'stream';
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.
* @type {76}
*/
const MIMECHUNK = 76;
export const MIMECHUNK = 76 as const;
/**
* 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
* @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;
/**
* @returns {string} the generated boundary
*/
function generate_boundary() {
let text = '';
const possible =
@ -45,12 +88,8 @@ function generate_boundary() {
return text;
}
/**
* @param {string} l the person to parse into an address
* @returns {string} the parsed address
*/
function person2address(l) {
return addressparser(l)
function convertPersonToAddress(person: string) {
return addressparser(person)
.map(({ name, address }) => {
return name
? `${mimeWordEncode(name).replace(/,/g, '=2C')} <${address}>`
@ -59,46 +98,31 @@ function person2address(l) {
.join(', ');
}
/**
* @param {string} header_name the header name to fix
* @returns {string} the fixed header name
*/
function fix_header_name_case(header_name) {
return header_name
function convertDashDelimitedTextToSnakeCase(text: string) {
return text
.toLowerCase()
.replace(/^(.)|-(.)/g, match => match.toUpperCase());
.replace(/^(.)|-(.)/g, (match) => match.toUpperCase());
}
class Message {
/**
* @typedef {Object} MessageHeaders
* @property {string?} content-type
* @property {string} [subject]
* @property {string} [text]
* @property {MessageAttachment} [attachment]
* @param {MessageHeaders} headers hash of message headers
*/
constructor(headers) {
this.attachments = [];
export class Message {
public readonly attachments: MessageAttachment[] = [];
public readonly header: Partial<MessageHeaders> = {
'message-id': `<${new Date().getTime()}.${counter++}.${
process.pid
}@${hostname()}>`,
date: getRFC2822Date(),
};
public readonly content: string = 'text/plain; charset=utf-8';
public readonly text?: string;
public alternative: AlternateMessageAttachment | null = null;
/**
* @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';
constructor(headers: Partial<MessageHeaders>) {
for (const header in headers) {
// allow user to override default content-type to override charset or send a single non-text message
if (/^content-type$/i.test(header)) {
this.content = headers[header];
this.content = headers[header] as string;
} else if (header === 'text') {
this.text = headers[header];
this.text = headers[header] as string;
} else if (
header === 'attachment' &&
typeof headers[header] === 'object'
@ -108,13 +132,15 @@ class Message {
for (let i = 0; i < attachment.length; i++) {
this.attach(attachment[i]);
}
} else {
} else if (attachment != null) {
this.attach(attachment);
}
} else if (header === 'subject') {
this.header.subject = mimeWordEncode(headers.subject);
} 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 {
// allow any headers the user wants to set??
// 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
* @returns {Message} the current instance for chaining
*/
attach(options) {
/*
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],
};
}
public attach(options: MessageAttachment): Message {
// sender can specify an attachment as an alternative
if (options.alternative) {
this.alternative = options;
@ -160,7 +175,7 @@ class Message {
* @param {string} [charset='utf-8'] the charset to encode as
* @returns {Message} the current Message instance
*/
attach_alternative(html, charset) {
attach_alternative(html: string, charset: string): Message {
this.alternative = {
data: html,
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.
* @returns {void}
*/
valid(callback) {
public valid(callback: (arg0: boolean, arg1?: string) => void) {
if (!this.header.from) {
callback(false, 'message does not have a valid sender');
}
@ -185,9 +201,9 @@ class Message {
} else if (this.attachments.length === 0) {
callback(true, undefined);
} else {
const failed = [];
const failed: string[] = [];
this.attachments.forEach(attachment => {
this.attachments.forEach((attachment) => {
if (attachment.path) {
if (fs.existsSync(attachment.path) == false) {
failed.push(`${attachment.path} does not exist`);
@ -206,132 +222,103 @@ class Message {
}
/**
* returns a stream of the current message
* @returns {MessageStream} a stream of the current message
* @public
* @returns {*} a stream of the current message
*/
stream() {
public stream() {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
return new MessageStream(this);
}
/**
* @public
* @param {function(Error, string): void} callback the function to call with the error and buffer
* @returns {void}
*/
read(callback) {
public read(callback: (err: Error, buffer: string) => void) {
let buffer = '';
const str = this.stream();
str.on('data', data => (buffer += data));
str.on('end', err => callback(err, buffer));
str.on('error', err => callback(err, buffer));
str.on('data', (data) => (buffer += data));
str.on('end', (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 {
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();
/**
* @type {Message}
*/
this.message = message;
/**
* @type {boolean}
*/
this.readable = true;
/**
* @type {boolean}
*/
this.paused = false;
/**
* @type {Buffer}
*/
this.buffer = Buffer.alloc(MIMECHUNK * 24 * 7);
/**
* @type {number}
*/
this.bufferIndex = 0;
/**
* @param {string} [data] the data to output
* @param {Function} [callback] the function
* @param {any[]} [args] array of arguments to pass to the callback
* @returns {void}
*/
const output_mixed = () => {
const boundary = generate_boundary();
output(
`Content-Type: multipart/mixed; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}`
);
const output = (data: string) => {
// can we buffer the data?
if (this.buffer != null) {
const bytes = Buffer.byteLength(data);
if (this.message.alternative == null) {
output_text(this.message);
output_message(boundary, this.message.attachments, 0, close);
} else {
const cb = () =>
output_message(boundary, this.message.attachments, 0, close);
output_alternative(this.message, cb);
}
};
/**
* @param {string} boundary the boundary text between outputs
* @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}
*/
const output_message = (boundary, list, index, callback) => {
if (index < list.length) {
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)
);
if (bytes + this.bufferIndex < this.buffer.length) {
this.buffer.write(data, this.bufferIndex);
this.bufferIndex += bytes;
}
// 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;
} 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}
*/
const output_attachment_headers = attachment => {
let data = [];
const headers = {
const output_attachment_headers = (
attachment: MessageAttachment | AlternateMessageAttachment
) => {
let data: string[] = [];
const headers: Partial<MessageHeaders> = {
'content-type':
attachment.type +
(attachment.charset ? `; charset=${attachment.charset}` : '') +
@ -339,19 +326,23 @@ class MessageStream extends Stream {
'content-transfer-encoding': 'base64',
'content-disposition': attachment.inline
? 'inline'
: `attachment; filename="${mimeWordEncode(attachment.name)}"`,
: `attachment; filename="${mimeWordEncode(
attachment.name as string
)}"`,
};
// allow sender to override default headers
for (const header in attachment.headers || {}) {
headers[header.toLowerCase()] = attachment.headers[header];
if (attachment.headers != null) {
for (const header in attachment.headers) {
headers[header.toLowerCase()] = attachment.headers[header];
}
}
for (const header in headers) {
data = data.concat([
fix_header_name_case(header),
convertDashDelimitedTextToSnakeCase(header),
': ',
headers[header],
headers[header] as string,
CRLF,
]);
}
@ -360,52 +351,38 @@ class MessageStream extends Stream {
};
/**
* @param {MessageAttachment} attachment the metadata to use as headers
* @param {function(): void} callback the function to call after output is finished
* @param {string} data the data to output as base64
* @param {function(): void} [callback] the function to call after output is finished
* @returns {void}
*/
const output_attachment = (attachment, callback) => {
const build = attachment.path
? output_file
: attachment.stream
? output_stream
: output_data;
output_attachment_headers(attachment);
build(attachment, callback);
const output_base64 = (data: string, callback?: () => void) => {
const loops = Math.ceil(data.length / MIMECHUNK);
let loop = 0;
while (loop < loops) {
output(data.substring(MIMECHUNK * loop, MIMECHUNK * (loop + 1)) + CRLF);
loop++;
}
if (callback) {
callback();
}
};
/**
* @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, 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 output_file = (
attachment: MessageAttachment | AlternateMessageAttachment,
next: (err: NodeJS.ErrnoException | null) => void
) => {
const chunk = MIME64CHUNK * 16;
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 {number} fd the file descriptor
* @returns {void}
*/
const opened = (err, fd) => {
const opened = (err: NodeJS.ErrnoException | null, fd: number) => {
if (!err) {
const read = (err, bytes) => {
const read = (err: NodeJS.ErrnoException | null, bytes: number) => {
if (!err && this.readable) {
let encoding =
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
* @returns {void}
*/
const output_stream = (attachment, callback) => {
if (attachment.stream.readable) {
const output_stream = (
attachment: MessageAttachment | AlternateMessageAttachment,
callback: () => void
) => {
if (attachment.stream != null && attachment.stream.readable) {
let previous = Buffer.alloc(0);
attachment.stream.resume();
attachment.stream.on('end', () => {
output_base64(previous.toString('base64'), callback);
this.removeListener('pause', attachment.stream.pause);
this.removeListener('resume', attachment.stream.resume);
this.removeListener('error', attachment.stream.resume);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.removeListener('pause', attachment.stream!.pause);
// 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?
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 {function(): void} [callback] the function to call after output is finished
* @param {string} boundary the boundary text between outputs
* @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}
*/
const output_base64 = (data, callback) => {
const loops = Math.ceil(data.length / MIMECHUNK);
let loop = 0;
while (loop < loops) {
output(data.substring(MIMECHUNK * loop, MIMECHUNK * (loop + 1)) + CRLF);
loop++;
}
if (callback) {
const output_message = (
boundary: string,
list: MessageAttachment[],
index: number,
callback: () => void
) => {
if (index < list.length) {
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();
}
};
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
* @returns {void}
*/
const output_text = message => {
let data = [];
const output_text = (message: Message) => {
let data: string[] = [];
data = data.concat([
'Content-Type:',
@ -529,12 +573,36 @@ class MessageStream extends Stream {
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 {function(): void} callback the function to call after output is finished
* @returns {void}
*/
const output_alternative = (message, callback) => {
const output_alternative = (
message: Message & { alternative: AlternateMessageAttachment },
callback: () => void
) => {
const boundary = generate_boundary();
output(
`Content-Type: multipart/alternative; boundary="${boundary}"${CRLF}${CRLF}--${boundary}${CRLF}`
@ -557,22 +625,24 @@ class MessageStream extends Stream {
}
};
/**
* @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, callback) => {
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();
});
});
const close = (err?: Error) => {
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');
};
/**
@ -593,18 +663,18 @@ class MessageStream extends Stream {
* @returns {void}
*/
const output_header = () => {
let data = [];
let data: string[] = [];
for (const header in this.message.header) {
// do not output BCC in the headers (regex) nor custom Object.prototype functions...
if (
!/bcc/i.test(header) &&
this.message.header.hasOwnProperty(header)
Object.prototype.hasOwnProperty.call(this.message.header, header)
) {
data = data.concat([
fix_header_name_case(header),
convertDashDelimitedTextToSnakeCase(header),
': ',
this.message.header[header],
this.message.header[header] as string,
CRLF,
]);
}
@ -614,109 +684,36 @@ class MessageStream extends Stream {
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);
process.nextTick(output_header);
}
/**
* @public
* pause the stream
* @returns {void}
*/
pause() {
public pause() {
this.paused = true;
this.emit('pause');
}
/**
* @public
* resume the stream
* @returns {void}
*/
resume() {
public resume() {
this.paused = false;
this.emit('resume');
}
/**
* @public
* destroy the stream
* @returns {void}
*/
destroy() {
public destroy() {
this.emit(
'destroy',
this.bufferIndex > 0 ? { message: 'message stream destroyed' } : null
@ -724,14 +721,11 @@ class MessageStream extends Stream {
}
/**
* @public
* destroy the stream at first opportunity
* @returns {void}
*/
destroySoon() {
public destroySoon() {
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');
const { createHmac } = require('crypto');
const { hostname } = require('os');
const { connect, createSecureContext, TLSSocket } = require('tls');
const { EventEmitter } = require('events');
import { Socket } from 'net';
import { createHmac } from 'crypto';
import { hostname } from 'os';
import { connect, createSecureContext, TLSSocket } from 'tls';
import { EventEmitter } from 'events';
const SMTPResponse = require('./response');
const SMTPError = require('./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';
import { SMTPResponse } from './response';
import { makeSMTPError, SMTPErrorStates } from './error';
/**
* @readonly
* @enum
*/
const AUTH_METHODS = {
PLAIN: /** @type {'PLAIN'} */ ('PLAIN'),
CRAM_MD5: /** @type {'CRAM-MD5'} */ ('CRAM-MD5'),
LOGIN: /** @type {'LOGIN'} */ ('LOGIN'),
XOAUTH2: /** @type {'XOAUTH2'} */ ('XOAUTH2'),
};
export const AUTH_METHODS = {
PLAIN: 'PLAIN',
'CRAM-MD5': 'CRAM-MD5',
LOGIN: 'LOGIN',
XOAUTH2: 'XOAUTH2',
} as const;
/**
* @readonly
* @enum
*/
const SMTPState = {
NOTCONNECTED: /** @type {0} */ (0),
CONNECTING: /** @type {1} */ (1),
CONNECTED: /** @type {2} */ (2),
};
export const SMTPState = {
NOTCONNECTED: 0,
CONNECTING: 1,
CONNECTED: 2,
} as const;
/**
* @type {0 | 1}
*/
let DEBUG = 0;
export const DEFAULT_TIMEOUT = 5000 as const;
const SMTP_PORT = 25 as const;
const SMTP_SSL_PORT = 465 as const;
const SMTP_TLS_PORT = 587 as const;
const CRLF = '\r\n' as const;
let DEBUG: 0 | 1 = 0;
/**
* @param {...any} args the message(s) to log
* @returns {void}
*/
const log = (...args) => {
const log = (...args: any[]) => {
if (DEBUG === 1) {
args.forEach(d =>
args.forEach((d) =>
console.log(
typeof d === 'object'
? d instanceof Error
@ -86,35 +60,63 @@ const log = (...args) => {
* @param {...*} args the arguments to apply to the function
* @returns {void}
*/
const caller = (callback, ...args) => {
const caller = (callback?: (...rest: any[]) => void, ...args: any[]) => {
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
*
* @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({
timeout,
@ -127,146 +129,98 @@ class SMTP extends EventEmitter {
tls,
logger,
authentication,
} = {}) {
}: Partial<SMTPConnectionOptions> = {}) {
super();
/**
* @private
* @type {0 | 1 | 2}
*/
this._state = SMTPState.NOTCONNECTED;
if (Array.isArray(authentication)) {
this.authentication = authentication;
}
/**
* @private
* @type {boolean}
*/
this._secure = false;
if (typeof timeout === 'number') {
this.timeout = timeout;
}
/**
* @type {Socket|TLSSocket}
*/
this.sock = null;
if (typeof domain === 'string') {
this.domain = domain;
}
/**
* @type {{ [i: string]: string | boolean }}
*/
this.features = null;
if (typeof host === 'string') {
this.host = host;
}
/**
* @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 =
if (
ssl != null &&
(typeof ssl === 'boolean' ||
(typeof ssl === 'object' && Array.isArray(ssl) === false))
? ssl
: false;
) {
this.ssl = ssl;
}
/**
* @type {boolean|SMTPSocketOptions}
*/
this.tls =
if (
tls != null &&
(typeof tls === 'boolean' ||
(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);
/**
* @type {boolean}
*/
this.loggedin = user && password ? false : true;
// keep these strings hidden when quicky debugging/logging
this.user = /** @returns {string} */ () => user;
this.password = /** @returns {string} */ () => password;
this.user = () => user as string;
this.password = () => password as string;
this.log = typeof logger === 'function' ? logger : log;
if (typeof logger === 'function') {
this.log = log;
}
}
/**
* @public
* @param {0 | 1} level -
* @returns {void}
*/
debug(level) {
public debug(level: 0 | 1) {
DEBUG = level;
}
/**
* @returns {number} the current state
* @public
* @returns {SMTPState} the current state
*/
state() {
public state() {
return this._state;
}
/**
* @public
* @returns {boolean} whether or not the instance is authorized
*/
authorized() {
public authorized() {
return this.loggedin;
}
/**
* @typedef {Object} ConnectOptions
* @property {boolean} [ssl]
*
* @public
* @param {function(...*): void} callback function to call after response
* @param {number} [port] the port to use for the connection
* @param {string} [host] the hostname to use for the connection
* @param {ConnectOptions} [options={}] the options
* @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.host = host;
this.ssl = options.ssl || this.ssl;
if (this._state !== SMTPState.NOTCONNECTED) {
this.quit(() =>
this.connect(
callback,
port,
host,
options
)
);
this.quit(() => this.connect(callback, port, host, options));
}
/**
@ -285,9 +239,9 @@ class SMTP extends EventEmitter {
this.close(true);
caller(
callback,
SMTPError(
makeSMTPError(
'could not establish an ssl connection',
SMTPError.CONNECTIONAUTH
SMTPErrorStates.CONNECTIONAUTH
)
);
} else {
@ -300,7 +254,7 @@ class SMTP extends EventEmitter {
* @param {Error} err err
* @returns {void}
*/
const connectedErrBack = err => {
const connectedErrBack = (err?: Error) => {
if (!err) {
connected();
} else {
@ -308,12 +262,19 @@ class SMTP extends EventEmitter {
this.log(err);
caller(
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 (this._state === SMTPState.NOTCONNECTED && !this.sock) {
return;
@ -331,9 +292,9 @@ class SMTP extends EventEmitter {
this.quit(() => {
caller(
callback,
SMTPError(
makeSMTPError(
'bad response on connection',
SMTPError.BADRESPONSE,
SMTPErrorStates.BADRESPONSE,
err,
msg.data
)
@ -354,14 +315,10 @@ class SMTP extends EventEmitter {
);
} else {
this.sock = new Socket();
this.sock.connect(
this.port,
this.host,
connectedErrBack
);
this.sock.connect(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.sock.once('response', response);
@ -369,11 +326,12 @@ class SMTP extends EventEmitter {
}
/**
* @public
* @param {string} str the string to send
* @param {*} callback function to call after response
* @returns {void}
*/
send(str, callback) {
public send(str: string, callback: (...args: any[]) => void) {
if (this.sock && this._state === SMTPState.CONNECTED) {
this.log(str);
@ -390,25 +348,36 @@ class SMTP extends EventEmitter {
this.close(true);
caller(
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 {function(...*): void} callback function to call after response
* @param {(number[] | number)} [codes=[250]] array codes
* @returns {void}
*/
command(cmd, callback, codes = [250]) {
public command(
cmd: string,
callback: (...rest: any[]) => void,
codes: number[] | number = [250]
) {
const codesArray = Array.isArray(codes)
? codes
: typeof codes === 'number'
? [codes]
: [250];
? [codes]
: [250];
const response = (err, msg) => {
const response = (
err: Error | null | undefined,
msg: { code: string | number; data: string; message: string }
) => {
if (err) {
caller(callback, err);
} else {
@ -421,7 +390,12 @@ class SMTP extends EventEmitter {
}'${suffix}`;
caller(
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
* host.
@ -440,7 +415,7 @@ class SMTP extends EventEmitter {
* @param {string} domain the domain to associate with the 'helo' request
* @returns {void}
*/
helo(callback, domain) {
public helo(callback: (...rest: any[]) => void, domain?: string) {
this.command(`helo ${domain || this.domain}`, (err, data) => {
if (err) {
caller(callback, err);
@ -452,11 +427,16 @@ class SMTP extends EventEmitter {
}
/**
* @public
* @param {function(...*): void} callback function to call after response
* @returns {void}
*/
starttls(callback) {
const response = (err, msg) => {
public starttls(callback: (...rest: any[]) => void) {
const response = (err: Error, msg: { data: unknown }) => {
if (this.sock == null) {
throw new Error('null socket');
}
if (err) {
err.message += ' while establishing a starttls session';
caller(callback, err);
@ -466,7 +446,7 @@ class SMTP extends EventEmitter {
);
const secureSocket = new TLSSocket(this.sock, { secureContext });
secureSocket.on('error', err => {
secureSocket.on('error', (err: Error) => {
this.close(true);
caller(callback, err);
});
@ -474,7 +454,7 @@ class SMTP extends EventEmitter {
this._secure = true;
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);
}
};
@ -483,15 +463,16 @@ class SMTP extends EventEmitter {
}
/**
* @public
* @param {string} data the string to parse for features
* @returns {void}
*/
parse_smtp_features(data) {
public parse_smtp_features(data: string) {
// According to RFC1869 some (badly written)
// MTA's will disconnect on an ehlo. Toss an exception if
// that happens -ddm
data.split('\n').forEach(ext => {
data.split('\n').forEach((ext) => {
const parse = ext.match(/^(?:\d+[-=]?)\s*?([^\s]+)(?:\s+(.*)\s*?)?$/);
// 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
// support using the old style.
if (parse != null) {
if (parse != null && this.features != null) {
// RFC 1869 requires a space between ehlo keyword and parameters.
// It's actually stricter, in that only spaces are allowed between
// 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 {string} domain the domain to associate with the 'ehlo' request
* @returns {void}
*/
ehlo(callback, domain) {
public ehlo(callback: (...rest: any[]) => void, domain?: string) {
this.features = {};
this.command(`ehlo ${domain || this.domain}`, (err, data) => {
if (err) {
@ -534,106 +516,116 @@ class SMTP extends EventEmitter {
}
/**
* @public
* @param {string} opt the features keyname to check
* @returns {boolean} whether the extension exists
*/
has_extn(opt) {
return this.features[opt.toLowerCase()] === undefined;
public has_extn(opt: string): boolean {
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 {string} domain the domain to associate with the 'help' request
* @returns {void}
*/
help(callback, domain) {
public help(callback: (...rest: any[]) => void, domain: string) {
this.command(domain ? `help ${domain}` : 'help', callback, [211, 214]);
}
/**
* @public
* @param {function(...*): void} callback function to call after response
* @returns {void}
*/
rset(callback) {
public rset(callback: (...rest: any[]) => void) {
this.command('rset', callback);
}
/**
* @public
* @param {function(...*): void} callback function to call after response
* @returns {void}
*/
noop(callback) {
public noop(callback: (...rest: any[]) => void) {
this.send('noop', callback);
}
/**
* @public
* @param {function(...*): void} callback function to call after response
* @param {string} from the sender
* @returns {void}
*/
mail(callback, from) {
public mail(callback: (...rest: any[]) => void, from: string) {
this.command(`mail FROM:${from}`, callback);
}
/**
* @public
* @param {function(...*): void} callback function to call after response
* @param {string} to the receiver
* @returns {void}
*/
rcpt(callback, to) {
public rcpt(callback: (...rest: any[]) => void, to: string) {
this.command(`RCPT TO:${to}`, callback, [250, 251]);
}
/**
* @public
* @param {function(...*): void} callback function to call after response
* @returns {void}
*/
data(callback) {
public data(callback: (...rest: any[]) => void) {
this.command('data', callback, [354]);
}
/**
* @public
* @param {function(...*): void} callback function to call after response
* @returns {void}
*/
data_end(callback) {
public data_end(callback: (...rest: any[]) => void) {
this.command(`${CRLF}.`, callback);
}
/**
* @public
* @param {string} data the message to send
* @returns {void}
*/
message(data) {
public message(data: string) {
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 {function(...*): void} callback function to call after response
* @returns {void}
*/
verify(address, callback) {
public verify(address: string, callback: (...rest: any[]) => void) {
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 {function(...*): void} callback function to call after response
* @returns {void}
*/
expn(address, callback) {
public expn(address: string, callback: (...rest: any[]) => void) {
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
* method tries ESMTP EHLO first.
@ -642,10 +634,14 @@ class SMTP extends EventEmitter {
* @param {string} [domain] the domain to associate with the command
* @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...?
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) => {
if (err) {
this.helo(response, domain);
@ -657,6 +653,8 @@ class SMTP extends EventEmitter {
}
/**
* @public
*
* Log in on an SMTP server that requires authentication.
*
* 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
* @returns {void}
*/
login(callback, user, password, options) {
public login(
callback: (...rest: any[]) => void,
user?: string,
password?: string,
options: { method?: string; domain?: string } = {}
) {
const login = {
user: user ? () => user : this.user,
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) {
caller(callback, err);
return;
}
let method = null;
let method: keyof typeof AUTH_METHODS | null = null;
/**
* @param {string} challenge challenge
* @returns {string} base64 cram hash
*/
const encode_cram_md5 = challenge => {
const encode_cram_md5 = (challenge: string): string => {
const hmac = createHmac('md5', login.password());
hmac.update(Buffer.from(challenge, 'base64').toString('ascii'));
return Buffer.from(`${login.user()} ${hmac.digest('hex')}`).toString(
@ -702,7 +705,7 @@ class SMTP extends EventEmitter {
/**
* @returns {string} base64 login/password
*/
const encode_plain = () =>
const encode_plain = (): string =>
Buffer.from(`\u0000${login.user()}\u0000${login.password()}`).toString(
'base64'
);
@ -711,7 +714,7 @@ class SMTP extends EventEmitter {
* @see https://developers.google.com/gmail/xoauth2_protocol
* @returns {string} base64 xoauth2 auth token
*/
const encode_xoauth2 = () =>
const encode_xoauth2 = (): string =>
Buffer.from(
`user=${login.user()}\u0001auth=Bearer ${login.password()}\u0001\u0001`
).toString('base64');
@ -722,10 +725,8 @@ class SMTP extends EventEmitter {
const preferred = this.authentication;
let auth = '';
if (this.features && this.features.auth) {
if (typeof this.features.auth === 'string') {
auth = this.features.auth;
}
if (typeof this.features?.auth === 'string') {
auth = this.features.auth;
}
for (let i = 0; i < preferred.length; i++) {
@ -742,12 +743,17 @@ class SMTP extends EventEmitter {
* @param {*} data data
* @returns {void}
*/
const failed = (err, data) => {
const failed = (err: Error, data: unknown) => {
this.loggedin = false;
this.close(); // if auth is bad, close the connection, it won't get better by itself
caller(
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
* @returns {void}
*/
const response = (err, data) => {
const response = (err: Error | null | undefined, data: unknown) => {
if (err) {
failed(err, data);
} else {
@ -771,11 +777,15 @@ class SMTP extends EventEmitter {
* @param {string} msg msg
* @returns {void}
*/
const attempt = (err, data, msg) => {
const attempt = (
err: Error | null | undefined,
data: unknown,
msg: string
) => {
if (err) {
failed(err, data);
} else {
if (method === AUTH_METHODS.CRAM_MD5) {
if (method === AUTH_METHODS['CRAM-MD5']) {
this.command(encode_cram_md5(msg), response, [235, 503]);
} else if (method === AUTH_METHODS.LOGIN) {
this.command(
@ -793,7 +803,7 @@ class SMTP extends EventEmitter {
* @param {string} msg msg
* @returns {void}
*/
const attempt_user = (err, data, msg) => {
const attempt_user = (err: Error, data: unknown) => {
if (err) {
failed(err, data);
} else {
@ -808,8 +818,8 @@ class SMTP extends EventEmitter {
};
switch (method) {
case AUTH_METHODS.CRAM_MD5:
this.command(`AUTH ${AUTH_METHODS.CRAM_MD5}`, attempt, [334]);
case AUTH_METHODS['CRAM-MD5']:
this.command(`AUTH ${AUTH_METHODS['CRAM-MD5']}`, attempt, [334]);
break;
case AUTH_METHODS.LOGIN:
this.command(`AUTH ${AUTH_METHODS.LOGIN}`, attempt_user, [334]);
@ -830,7 +840,12 @@ class SMTP extends EventEmitter {
break;
default:
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);
break;
}
@ -840,10 +855,11 @@ class SMTP extends EventEmitter {
}
/**
* @public
* @param {boolean} [force=false] whether or not to force destroy the connection
* @returns {void}
*/
close(force = false) {
public close(force = false) {
if (this.sock) {
if (force) {
this.log('smtp connection destroyed!');
@ -867,10 +883,11 @@ class SMTP extends EventEmitter {
}
/**
* @public
* @param {function(...*): void} [callback] function to call after response
* @returns {void}
*/
quit(callback) {
public quit(callback?: (...rest: any[]) => void) {
this.command(
'quit',
(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