emailjs/test/client.ts

424 lines
11 KiB
TypeScript

import { promisify } from 'util';
import test from 'ava';
import { simpleParser, ParsedMail } from 'mailparser';
import { SMTPServer } from 'smtp-server';
import {
DEFAULT_TIMEOUT,
SMTPClient,
Message,
MessageHeaders,
isRFC2822Date,
} from '../email';
const parseMap = new Map<string, ParsedMail>();
const port = 3000;
let greylistPort = 4000;
const client = new SMTPClient({
port,
user: 'pooh',
password: 'honey',
ssl: true,
});
const server = new SMTPServer({
secure: true,
onAuth(auth, _session, callback) {
if (auth.username === 'pooh' && auth.password === 'honey') {
callback(null, { user: 'pooh' });
} else {
return callback(new Error('invalid user / pass'));
}
},
async onData(stream, _session, callback: () => void) {
const mail = await simpleParser(stream, {
skipHtmlToText: true,
skipTextToHtml: true,
skipImageLinks: true,
} as Record<string, unknown>);
parseMap.set(mail.subject as string, mail);
callback();
},
});
async function send(headers: Partial<MessageHeaders>) {
return new Promise<ParsedMail>((resolve, reject) => {
client.send(new Message(headers), (err) => {
if (err) {
reject(err);
} else {
resolve(parseMap.get(headers.subject as string) as ParsedMail);
}
});
});
}
test.before(async (t) => {
server.listen(port, t.pass);
});
test.after(async (t) => {
server.close(t.pass);
});
test('client invokes callback exactly once for invalid connection', async (t) => {
const msg = {
from: 'foo@bar.baz',
to: 'foo@bar.baz',
subject: 'hello world',
text: 'hello world',
};
await t.notThrowsAsync(
new Promise<void>((resolve, reject) => {
let counter = 0;
const invalidClient = new SMTPClient({ host: 'bar.baz' });
const incrementListener = () => {
if (counter > 0) {
reject();
} else {
counter++;
}
};
invalidClient.smtp.addListener('incrementTestCounter', incrementListener);
invalidClient.send(new Message(msg), (err) => {
if (err == null || counter > 0) {
reject();
} else {
invalidClient.smtp.emit('incrementTestCounter');
}
});
// @ts-expect-error the error event is only accessible from the protected socket property
invalidClient.smtp.sock.once('error', () => {
invalidClient.smtp.removeListener(
'incrementTestCounter',
incrementListener
);
if (counter === 1) {
resolve();
} else {
reject();
}
});
})
);
});
test('client has a default connection 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 SMTPClient(connectionOptions).smtp.timeout, DEFAULT_TIMEOUT);
connectionOptions.timeout = null;
t.is(new SMTPClient(connectionOptions).smtp.timeout, DEFAULT_TIMEOUT);
connectionOptions.timeout = undefined;
t.is(new SMTPClient(connectionOptions).smtp.timeout, DEFAULT_TIMEOUT);
});
test('client deduplicates recipients', async (t) => {
const msg = {
from: 'zelda@gmail.com',
to: 'gannon@gmail.com',
cc: 'gannon@gmail.com',
bcc: 'gannon@gmail.com',
};
const stack = client.createMessageStack(new Message(msg));
t.true(stack.to.length === 1);
t.is(stack.to[0].address, 'gannon@gmail.com');
});
test('client accepts array recipients', async (t) => {
const msg = new Message({
from: 'zelda@gmail.com',
to: ['gannon1@gmail.com'],
cc: ['gannon2@gmail.com'],
bcc: ['gannon3@gmail.com'],
});
msg.header.to = [msg.header.to as string];
msg.header.cc = [msg.header.cc as string];
msg.header.bcc = [msg.header.bcc as string];
const isValid = await new Promise((r) => msg.valid(r));
const stack = client.createMessageStack(msg);
t.true(isValid);
t.is(stack.to.length, 3);
t.deepEqual(
stack.to.map((x) => x.address),
['gannon1@gmail.com', 'gannon2@gmail.com', 'gannon3@gmail.com']
);
});
test('client accepts array sender', async (t) => {
const msg = new Message({
from: ['zelda@gmail.com'],
to: ['gannon1@gmail.com'],
});
msg.header.from = [msg.header.from as string];
const isValid = await new Promise((r) => msg.valid(r));
t.true(isValid);
});
test('client rejects message without `from` header', async (t) => {
const { message: error } = await t.throwsAsync(
send({
subject: 'this is a test TEXT message from emailjs',
text: "It is hard to be brave when you're only a Very Small Animal.",
})
);
t.is(error, 'Message must have a `from` header');
});
test('client rejects message without `to`, `cc`, or `bcc` header', async (t) => {
const { message: error } = await t.throwsAsync(
send({
subject: 'this is a test TEXT message from emailjs',
from: 'piglet@gmail.com',
text: "It is hard to be brave when you're only a Very Small Animal.",
})
);
t.is(error, 'Message must have at least one `to`, `cc`, or `bcc` header');
});
test('client allows message with only `cc` recipient header', async (t) => {
const msg = {
subject: 'this is a test TEXT message from emailjs',
from: 'piglet@gmail.com',
cc: 'pooh@gmail.com',
text: "It is hard to be brave when you're only a Very Small Animal.",
};
const mail = await send(msg);
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.cc?.text, msg.cc);
});
test('client allows message with only `bcc` recipient header', async (t) => {
const msg = {
subject: 'this is a test TEXT message from emailjs',
from: 'piglet@gmail.com',
bcc: 'pooh@gmail.com',
text: "It is hard to be brave when you're only a Very Small Animal.",
};
const mail = await send(msg);
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.bcc, undefined);
});
test('client constructor throws if `password` supplied without `user`', async (t) => {
t.notThrows(() => new SMTPClient({ user: 'anything', password: 'anything' }));
t.throws(() => new SMTPClient({ password: 'anything' }));
t.throws(
() =>
new SMTPClient({ username: 'anything', password: 'anything' } as Record<
string,
unknown
>)
);
});
test('client supports greylisting', async (t) => {
t.plan(3);
const msg = {
subject: 'this is a test TEXT message from emailjs',
from: 'piglet@gmail.com',
bcc: 'pooh@gmail.com',
text: "It is hard to be brave when you're only a Very Small Animal.",
};
const greylistServer = new SMTPServer({
secure: true,
onRcptTo(_address, _session, callback) {
t.pass();
callback();
},
onAuth(auth, _session, callback) {
if (auth.username === 'pooh' && auth.password === 'honey') {
callback(null, { user: 'pooh' });
} else {
return callback(new Error('invalid user / pass'));
}
},
});
const { onRcptTo } = greylistServer;
greylistServer.onRcptTo = (_address, _session, callback) => {
greylistServer.onRcptTo = (a, s, cb) => {
t.pass();
const err = new Error('greylist');
((err as never) as { responseCode: number }).responseCode = 450;
greylistServer.onRcptTo = onRcptTo;
onRcptTo(a, s, cb);
};
const err = new Error('greylist');
((err as never) as { responseCode: number }).responseCode = 450;
callback(err);
};
const p = greylistPort++;
await t.notThrowsAsync(
new Promise<void>((resolve, reject) => {
greylistServer.listen(p, () => {
new SMTPClient({
port: p,
user: 'pooh',
password: 'honey',
ssl: true,
}).send(new Message(msg), (err) => {
greylistServer.close();
if (err) {
reject(err);
} else {
resolve();
}
});
});
})
);
});
test('client only responds once to greylisting', async (t) => {
t.plan(4);
const msg = {
subject: 'this is a test TEXT message from emailjs',
from: 'piglet@gmail.com',
bcc: 'pooh@gmail.com',
text: "It is hard to be brave when you're only a Very Small Animal.",
};
const greylistServer = new SMTPServer({
secure: true,
onRcptTo(_address, _session, callback) {
t.pass();
const err = new Error('greylist');
((err as never) as { responseCode: number }).responseCode = 450;
callback(err);
},
onAuth(auth, _session, callback) {
if (auth.username === 'pooh' && auth.password === 'honey') {
callback(null, { user: 'pooh' });
} else {
return callback(new Error('invalid user / pass'));
}
},
});
const p = greylistPort++;
const { message: error } = await t.throwsAsync(
new Promise<void>((resolve, reject) => {
greylistServer.listen(p, () => {
new SMTPClient({
port: p,
user: 'pooh',
password: 'honey',
ssl: true,
}).send(new Message(msg), (err) => {
greylistServer.close();
if (err) {
reject(err);
} else {
resolve();
}
});
});
})
);
t.is(error, "bad response on command 'RCPT': greylist");
});
test('client send can have result awaited when promisified', async (t) => {
// bind necessary to retain internal access to client prototype
const sendAsync = promisify(client.send.bind(client));
const msg = {
subject: 'this is a test TEXT message from emailjs',
from: 'piglet@gmail.com',
bcc: 'pooh@gmail.com',
text: "It is hard to be brave when you're only a Very Small Animal.",
};
try {
const message = await sendAsync(new Message(msg));
t.true(message instanceof Message);
t.like(message, {
alternative: null,
content: 'text/plain; charset=utf-8',
text: "It is hard to be brave when you're only a Very Small Animal.",
header: {
bcc: 'pooh@gmail.com',
from: 'piglet@gmail.com',
subject: '=?UTF-8?Q?this_is_a_test_TEXT_message_from_emailjs?=',
},
});
t.deepEqual(message.attachments, []);
t.true(isRFC2822Date(message.header.date as string));
t.regex(message.header['message-id'] as string, /^<.*[@]{1}.*>$/);
} catch (err) {
t.fail(err);
}
});
test('client sendAsync can have result awaited', async (t) => {
const msg = {
subject: 'this is a test TEXT message from emailjs',
from: 'piglet@gmail.com',
bcc: 'pooh@gmail.com',
text: "It is hard to be brave when you're only a Very Small Animal.",
};
try {
const message = await client.sendAsync(new Message(msg));
t.true(message instanceof Message);
t.like(message, {
alternative: null,
content: 'text/plain; charset=utf-8',
text: "It is hard to be brave when you're only a Very Small Animal.",
header: {
bcc: 'pooh@gmail.com',
from: 'piglet@gmail.com',
subject: '=?UTF-8?Q?this_is_a_test_TEXT_message_from_emailjs?=',
},
});
t.deepEqual(message.attachments, []);
t.true(isRFC2822Date(message.header.date as string));
t.regex(message.header['message-id'] as string, /^<.*[@]{1}.*>$/);
} catch (err) {
t.fail(err);
}
});
test('client sendAsync can have error caught when awaited', async (t) => {
const msg = {
subject: 'this is a test TEXT message from emailjs',
from: 'piglet@gmail.com',
bcc: 'pooh@gmail.com',
text: "It is hard to be brave when you're only a Very Small Animal.",
};
try {
const invalidClient = new SMTPClient({ host: 'bar.baz' });
const message = await invalidClient.sendAsync(new Message(msg));
t.true(message instanceof Message);
t.fail();
} catch (err) {
t.true(err instanceof Error);
t.pass();
}
});