@remit/smtp-service 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/message-builder.test.ts +117 -0
- package/src/smtp-client.test.ts +209 -0
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/smtp-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test:typecheck": "tsgo --noEmit",
|
|
8
|
-
"test:run": "node --env-file=../../localhost-test-unit.env --test 'src/**/*.test.ts'",
|
|
8
|
+
"test:run": "node --env-file=../../localhost-test-unit.env --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=70 --test 'src/**/*.test.ts'",
|
|
9
9
|
"test": "npm run test:typecheck && npm run test:run",
|
|
10
10
|
"test:integ": "RUN_INTEG_TESTS=true node --env-file=../../localhost-dev-aws.env --import tsx --test --test-force-exit 'src/**/*.integ.test.ts'"
|
|
11
11
|
},
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { OutboxMessageItem } from "@remit/data-ports";
|
|
4
|
+
import { buildMailMessage, type MailAttachment } from "./message-builder.js";
|
|
5
|
+
|
|
6
|
+
const baseOutbox = (
|
|
7
|
+
overrides: Partial<OutboxMessageItem> = {},
|
|
8
|
+
): OutboxMessageItem => ({
|
|
9
|
+
outboxMessageId: "outbox-1",
|
|
10
|
+
accountId: "account-1",
|
|
11
|
+
accountConfigId: "config-1",
|
|
12
|
+
fromAddress: "sender@example.com",
|
|
13
|
+
toAddresses: ["to@example.com"],
|
|
14
|
+
ccAddresses: [],
|
|
15
|
+
bccAddresses: [],
|
|
16
|
+
messageIdValue: "generated-id@example.com",
|
|
17
|
+
references: [],
|
|
18
|
+
status: "queued",
|
|
19
|
+
createdAt: 1_700_000_000_000,
|
|
20
|
+
updatedAt: 1_700_000_000_000,
|
|
21
|
+
...overrides,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("buildMailMessage", () => {
|
|
25
|
+
it("formats From as a quoted display name when fromName is set", () => {
|
|
26
|
+
const message = buildMailMessage(
|
|
27
|
+
baseOutbox({
|
|
28
|
+
fromName: "Alice Sender",
|
|
29
|
+
fromAddress: "alice@example.com",
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
assert.equal(message.from, '"Alice Sender" <alice@example.com>');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("uses the bare address as From when fromName is absent", () => {
|
|
36
|
+
const message = buildMailMessage(
|
|
37
|
+
baseOutbox({ fromName: undefined, fromAddress: "alice@example.com" }),
|
|
38
|
+
);
|
|
39
|
+
assert.equal(message.from, "alice@example.com");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("wraps messageIdValue in angle brackets", () => {
|
|
43
|
+
const message = buildMailMessage(
|
|
44
|
+
baseOutbox({ messageIdValue: "abc.123@example.com" }),
|
|
45
|
+
);
|
|
46
|
+
assert.equal(message.messageId, "<abc.123@example.com>");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("carries recipient and body fields through unchanged", () => {
|
|
50
|
+
const message = buildMailMessage(
|
|
51
|
+
baseOutbox({
|
|
52
|
+
toAddresses: ["a@example.com", "b@example.com"],
|
|
53
|
+
ccAddresses: ["c@example.com"],
|
|
54
|
+
bccAddresses: ["d@example.com"],
|
|
55
|
+
replyToAddress: "reply@example.com",
|
|
56
|
+
subject: "Hello",
|
|
57
|
+
textBody: "plain text",
|
|
58
|
+
htmlBody: "<p>html</p>",
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
assert.deepEqual(message.to, ["a@example.com", "b@example.com"]);
|
|
62
|
+
assert.deepEqual(message.cc, ["c@example.com"]);
|
|
63
|
+
assert.deepEqual(message.bcc, ["d@example.com"]);
|
|
64
|
+
assert.equal(message.replyTo, "reply@example.com");
|
|
65
|
+
assert.equal(message.subject, "Hello");
|
|
66
|
+
assert.equal(message.text, "plain text");
|
|
67
|
+
assert.equal(message.html, "<p>html</p>");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("wraps inReplyTo in angle brackets when present", () => {
|
|
71
|
+
const message = buildMailMessage(
|
|
72
|
+
baseOutbox({ inReplyTo: "parent@example.com" }),
|
|
73
|
+
);
|
|
74
|
+
assert.equal(message.inReplyTo, "<parent@example.com>");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("leaves inReplyTo undefined when absent", () => {
|
|
78
|
+
const message = buildMailMessage(baseOutbox({ inReplyTo: undefined }));
|
|
79
|
+
assert.equal(message.inReplyTo, undefined);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("angle-brackets each reference and joins them with spaces", () => {
|
|
83
|
+
const message = buildMailMessage(
|
|
84
|
+
baseOutbox({ references: ["one@example.com", "two@example.com"] }),
|
|
85
|
+
);
|
|
86
|
+
assert.equal(message.references, "<one@example.com> <two@example.com>");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("produces an empty references string when there are none", () => {
|
|
90
|
+
const message = buildMailMessage(baseOutbox({ references: [] }));
|
|
91
|
+
assert.equal(message.references, "");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("passes attachments through to the built message", () => {
|
|
95
|
+
const attachments: MailAttachment[] = [
|
|
96
|
+
{
|
|
97
|
+
filename: "invoice.pdf",
|
|
98
|
+
content: Buffer.from("pdf-bytes"),
|
|
99
|
+
contentType: "application/pdf",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
filename: "logo.png",
|
|
103
|
+
content: Buffer.from("png-bytes"),
|
|
104
|
+
contentType: "image/png",
|
|
105
|
+
cid: "logo-cid",
|
|
106
|
+
contentDisposition: "inline",
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
const message = buildMailMessage(baseOutbox(), attachments);
|
|
110
|
+
assert.equal(message.attachments, attachments);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("leaves attachments undefined when none are provided", () => {
|
|
114
|
+
const message = buildMailMessage(baseOutbox());
|
|
115
|
+
assert.equal(message.attachments, undefined);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { after, describe, it } from "node:test";
|
|
4
|
+
import type { MailMessage } from "./message-builder.js";
|
|
5
|
+
import {
|
|
6
|
+
type SmtpConfig,
|
|
7
|
+
SmtpConnectionError,
|
|
8
|
+
type SmtpCredentials,
|
|
9
|
+
sendMail,
|
|
10
|
+
} from "./smtp-client.js";
|
|
11
|
+
|
|
12
|
+
interface FakeSmtpOptions {
|
|
13
|
+
authResponse?: string;
|
|
14
|
+
dataFinalResponse?: string;
|
|
15
|
+
dropAfterGreeting?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface FakeSmtp {
|
|
19
|
+
port: number;
|
|
20
|
+
authCommands: string[];
|
|
21
|
+
close: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const startFakeSmtp = (opts: FakeSmtpOptions = {}): Promise<FakeSmtp> =>
|
|
25
|
+
new Promise((resolve) => {
|
|
26
|
+
const authCommands: string[] = [];
|
|
27
|
+
const server = net.createServer((socket) => {
|
|
28
|
+
if (opts.dropAfterGreeting) {
|
|
29
|
+
socket.destroy();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
socket.write("220 fake ESMTP\r\n");
|
|
33
|
+
let inData = false;
|
|
34
|
+
socket.on("data", (buffer) => {
|
|
35
|
+
const line = buffer.toString();
|
|
36
|
+
if (inData) {
|
|
37
|
+
if (line.includes("\r\n.\r\n")) {
|
|
38
|
+
inData = false;
|
|
39
|
+
socket.write(opts.dataFinalResponse ?? "250 2.0.0 Ok\r\n");
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const command = line.slice(0, 4).toUpperCase();
|
|
44
|
+
if (command.startsWith("EHLO") || command.startsWith("HELO")) {
|
|
45
|
+
socket.write("250-fake\r\n250 AUTH PLAIN LOGIN XOAUTH2\r\n");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (command.startsWith("AUTH")) {
|
|
49
|
+
authCommands.push(line.trim());
|
|
50
|
+
socket.write(opts.authResponse ?? "235 2.7.0 Accepted\r\n");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (command.startsWith("MAIL") || command.startsWith("RCPT")) {
|
|
54
|
+
socket.write("250 2.1.0 Ok\r\n");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (command.startsWith("DATA")) {
|
|
58
|
+
socket.write("354 End data\r\n");
|
|
59
|
+
inData = true;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (command.startsWith("QUIT")) {
|
|
63
|
+
socket.write("221 Bye\r\n");
|
|
64
|
+
socket.end();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
socket.write("250 Ok\r\n");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
server.listen(0, "127.0.0.1", () => {
|
|
71
|
+
const address = server.address();
|
|
72
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
73
|
+
resolve({
|
|
74
|
+
port,
|
|
75
|
+
authCommands,
|
|
76
|
+
close: () => server.close(),
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const configFor = (
|
|
82
|
+
port: number,
|
|
83
|
+
credentials: SmtpCredentials = { kind: "password", password: "secret" },
|
|
84
|
+
): SmtpConfig => ({
|
|
85
|
+
host: "127.0.0.1",
|
|
86
|
+
port,
|
|
87
|
+
secure: false,
|
|
88
|
+
user: "user@example.com",
|
|
89
|
+
credentials,
|
|
90
|
+
connectionTimeout: 2000,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const testMessage: MailMessage = {
|
|
94
|
+
from: "user@example.com",
|
|
95
|
+
to: ["recipient@example.com"],
|
|
96
|
+
subject: "Subject",
|
|
97
|
+
text: "Body",
|
|
98
|
+
messageId: "<message@example.com>",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
describe("sendMail", () => {
|
|
102
|
+
const servers: FakeSmtp[] = [];
|
|
103
|
+
const spawn = async (opts?: FakeSmtpOptions): Promise<FakeSmtp> => {
|
|
104
|
+
const server = await startFakeSmtp(opts);
|
|
105
|
+
servers.push(server);
|
|
106
|
+
return server;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
after(() => {
|
|
110
|
+
for (const server of servers) {
|
|
111
|
+
server.close();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns success with the SMTP response on delivery", async () => {
|
|
116
|
+
const server = await spawn();
|
|
117
|
+
const result = await sendMail(configFor(server.port), testMessage);
|
|
118
|
+
|
|
119
|
+
assert.equal(result.success, true);
|
|
120
|
+
assert.equal(result.isTransient, false);
|
|
121
|
+
assert.ok(result.response);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("sends attachments through the transport", async () => {
|
|
125
|
+
const server = await spawn();
|
|
126
|
+
const result = await sendMail(configFor(server.port), {
|
|
127
|
+
...testMessage,
|
|
128
|
+
html: "<p>hi</p>",
|
|
129
|
+
cc: ["cc@example.com"],
|
|
130
|
+
bcc: ["bcc@example.com"],
|
|
131
|
+
replyTo: "reply@example.com",
|
|
132
|
+
inReplyTo: "<parent@example.com>",
|
|
133
|
+
references: "<a@example.com> <b@example.com>",
|
|
134
|
+
attachments: [
|
|
135
|
+
{
|
|
136
|
+
filename: "note.txt",
|
|
137
|
+
content: Buffer.from("hello"),
|
|
138
|
+
contentType: "text/plain",
|
|
139
|
+
contentDisposition: "attachment",
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
assert.equal(result.success, true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("uses XOAUTH2 for access-token credentials", async () => {
|
|
148
|
+
const server = await spawn();
|
|
149
|
+
const result = await sendMail(
|
|
150
|
+
configFor(server.port, { kind: "accessToken", accessToken: "token-abc" }),
|
|
151
|
+
testMessage,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
assert.equal(result.success, true);
|
|
155
|
+
assert.ok(
|
|
156
|
+
server.authCommands.some((command) => command.startsWith("AUTH XOAUTH2")),
|
|
157
|
+
"expected an AUTH XOAUTH2 command",
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("throws a classified auth error on a 535 AUTH rejection", async () => {
|
|
162
|
+
const server = await spawn({
|
|
163
|
+
authResponse: "535 5.7.8 Authentication credentials invalid\r\n",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await assert.rejects(
|
|
167
|
+
sendMail(configFor(server.port), testMessage),
|
|
168
|
+
(error: unknown) => {
|
|
169
|
+
assert.ok(error instanceof SmtpConnectionError);
|
|
170
|
+
assert.equal(error.kind, "auth");
|
|
171
|
+
assert.equal(error.message, "SMTP authentication failed");
|
|
172
|
+
return true;
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("treats a 5xx delivery rejection as a permanent failure", async () => {
|
|
178
|
+
const server = await spawn({
|
|
179
|
+
dataFinalResponse: "550 5.0.0 Rejected\r\n",
|
|
180
|
+
});
|
|
181
|
+
const result = await sendMail(configFor(server.port), testMessage);
|
|
182
|
+
|
|
183
|
+
assert.equal(result.success, false);
|
|
184
|
+
assert.equal(result.smtpCode, 550);
|
|
185
|
+
assert.equal(result.isTransient, false);
|
|
186
|
+
assert.ok(result.error);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("treats a 4xx delivery rejection as a transient failure", async () => {
|
|
190
|
+
const server = await spawn({
|
|
191
|
+
dataFinalResponse: "451 4.3.0 Try again later\r\n",
|
|
192
|
+
});
|
|
193
|
+
const result = await sendMail(configFor(server.port), testMessage);
|
|
194
|
+
|
|
195
|
+
assert.equal(result.success, false);
|
|
196
|
+
assert.equal(result.smtpCode, 451);
|
|
197
|
+
assert.equal(result.isTransient, true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("returns a non-transient failure when the socket errors without an SMTP code", async () => {
|
|
201
|
+
const server = await spawn({ dropAfterGreeting: true });
|
|
202
|
+
const result = await sendMail(configFor(server.port), testMessage);
|
|
203
|
+
|
|
204
|
+
assert.equal(result.success, false);
|
|
205
|
+
assert.equal(result.isTransient, false);
|
|
206
|
+
assert.equal(result.smtpCode, undefined);
|
|
207
|
+
assert.ok(result.error);
|
|
208
|
+
});
|
|
209
|
+
});
|