@remit/smtp-service 1.0.3 → 1.0.4
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 +1 -1
- package/src/smtp-client.test.ts +21 -7
- package/src/smtp-client.ts +64 -10
- package/src/test-fake-smtp.ts +7 -2
package/package.json
CHANGED
package/src/smtp-client.test.ts
CHANGED
|
@@ -167,14 +167,28 @@ describe("sendMail", () => {
|
|
|
167
167
|
assert.equal(result.isTransient, true);
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
-
it("
|
|
171
|
-
const server = await spawn({
|
|
172
|
-
const result = await sendMail(configFor(server.port), testMessage);
|
|
170
|
+
it("throws a network SmtpConnectionError before data when the server drops before the greeting", async () => {
|
|
171
|
+
const server = await spawn({ dropBeforeGreeting: true });
|
|
173
172
|
|
|
174
|
-
assert.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
173
|
+
await assert.rejects(
|
|
174
|
+
() => sendMail(configFor(server.port), testMessage),
|
|
175
|
+
(error: unknown) =>
|
|
176
|
+
error instanceof SmtpConnectionError &&
|
|
177
|
+
error.kind === "network" &&
|
|
178
|
+
!error.afterData,
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("marks a drop after the whole message was written as after data", async () => {
|
|
183
|
+
const server = await spawn({ dropAfterData: true });
|
|
184
|
+
|
|
185
|
+
await assert.rejects(
|
|
186
|
+
() => sendMail(configFor(server.port), testMessage),
|
|
187
|
+
(error: unknown) =>
|
|
188
|
+
error instanceof SmtpConnectionError &&
|
|
189
|
+
error.kind === "network" &&
|
|
190
|
+
error.afterData,
|
|
191
|
+
);
|
|
178
192
|
});
|
|
179
193
|
});
|
|
180
194
|
|
package/src/smtp-client.ts
CHANGED
|
@@ -25,11 +25,19 @@ export type SmtpErrorKind = "auth" | "network";
|
|
|
25
25
|
*/
|
|
26
26
|
export class SmtpConnectionError extends Error {
|
|
27
27
|
readonly kind: SmtpErrorKind;
|
|
28
|
+
/** The whole message, end-of-data included, was written before it failed. */
|
|
29
|
+
readonly afterData: boolean;
|
|
28
30
|
|
|
29
|
-
constructor(
|
|
31
|
+
constructor(
|
|
32
|
+
kind: SmtpErrorKind,
|
|
33
|
+
message: string,
|
|
34
|
+
cause?: unknown,
|
|
35
|
+
afterData = false,
|
|
36
|
+
) {
|
|
30
37
|
super(message, { cause });
|
|
31
38
|
this.name = "SmtpConnectionError";
|
|
32
39
|
this.kind = kind;
|
|
40
|
+
this.afterData = afterData;
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -95,19 +103,59 @@ type SmtpFailure = Error & {
|
|
|
95
103
|
code?: string;
|
|
96
104
|
command?: string;
|
|
97
105
|
response?: string;
|
|
106
|
+
originalCode?: string;
|
|
98
107
|
};
|
|
99
108
|
|
|
109
|
+
/**
|
|
110
|
+
* nodemailer's own codes for a connection that failed. It moves the system
|
|
111
|
+
* code (`ECONNRESET`, `ECONNREFUSED`) to `originalCode`.
|
|
112
|
+
*/
|
|
100
113
|
const NETWORK_ERROR_CODES = new Set([
|
|
101
|
-
"
|
|
114
|
+
"ECONNECTION",
|
|
115
|
+
"ESOCKET",
|
|
102
116
|
"ETIMEDOUT",
|
|
103
|
-
"
|
|
104
|
-
"
|
|
105
|
-
"EHOSTUNREACH",
|
|
117
|
+
"EDNS",
|
|
118
|
+
"ETLS",
|
|
106
119
|
]);
|
|
107
120
|
|
|
121
|
+
const noop = (): void => undefined;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A nodemailer logger that only records whether the whole message, end of
|
|
125
|
+
* data included, was written to the socket: the one point after which the
|
|
126
|
+
* server may hold a copy.
|
|
127
|
+
*/
|
|
128
|
+
const dataWatch = () => {
|
|
129
|
+
const watch = {
|
|
130
|
+
afterData: false,
|
|
131
|
+
logger: {
|
|
132
|
+
trace: noop,
|
|
133
|
+
debug: noop,
|
|
134
|
+
warn: noop,
|
|
135
|
+
error: noop,
|
|
136
|
+
fatal: noop,
|
|
137
|
+
info: (entry: unknown) => {
|
|
138
|
+
if (
|
|
139
|
+
typeof entry === "object" &&
|
|
140
|
+
entry !== null &&
|
|
141
|
+
"tnx" in entry &&
|
|
142
|
+
entry.tnx === "message"
|
|
143
|
+
) {
|
|
144
|
+
watch.afterData = true;
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
return watch;
|
|
150
|
+
};
|
|
151
|
+
|
|
108
152
|
const VERIFY_TIMEOUT_MS = 12_000;
|
|
109
153
|
|
|
110
|
-
const transporterFor = (
|
|
154
|
+
const transporterFor = (
|
|
155
|
+
config: SmtpConfig,
|
|
156
|
+
socketTimeout: number,
|
|
157
|
+
logger: ReturnType<typeof dataWatch>["logger"] | false = false,
|
|
158
|
+
) => {
|
|
111
159
|
const timeout = config.connectionTimeout ?? 30_000;
|
|
112
160
|
return nodemailer.createTransport({
|
|
113
161
|
host: config.host,
|
|
@@ -119,10 +167,14 @@ const transporterFor = (config: SmtpConfig, socketTimeout: number) => {
|
|
|
119
167
|
greetingTimeout: timeout,
|
|
120
168
|
socketTimeout,
|
|
121
169
|
getSocket: config.getSocket,
|
|
170
|
+
logger,
|
|
122
171
|
});
|
|
123
172
|
};
|
|
124
173
|
|
|
125
|
-
const classifySmtpError = (
|
|
174
|
+
const classifySmtpError = (
|
|
175
|
+
error: SmtpFailure,
|
|
176
|
+
afterData = false,
|
|
177
|
+
): SmtpConnectionError | null => {
|
|
126
178
|
const smtpCode = error.responseCode;
|
|
127
179
|
|
|
128
180
|
if (
|
|
@@ -145,8 +197,9 @@ const classifySmtpError = (error: SmtpFailure): SmtpConnectionError | null => {
|
|
|
145
197
|
if (error.code !== undefined && NETWORK_ERROR_CODES.has(error.code)) {
|
|
146
198
|
return new SmtpConnectionError(
|
|
147
199
|
"network",
|
|
148
|
-
`SMTP connection failed: ${error.code}`,
|
|
200
|
+
`SMTP connection failed: ${error.originalCode ?? error.code}`,
|
|
149
201
|
error,
|
|
202
|
+
afterData,
|
|
150
203
|
);
|
|
151
204
|
}
|
|
152
205
|
|
|
@@ -160,7 +213,8 @@ export const sendMail = async (
|
|
|
160
213
|
config: SmtpConfig,
|
|
161
214
|
message: MailMessage,
|
|
162
215
|
): Promise<SendResult> => {
|
|
163
|
-
const
|
|
216
|
+
const watch = dataWatch();
|
|
217
|
+
const transporter = transporterFor(config, 300_000, watch.logger);
|
|
164
218
|
|
|
165
219
|
return transporter
|
|
166
220
|
.sendMail(toNodemailerOptions(message))
|
|
@@ -171,7 +225,7 @@ export const sendMail = async (
|
|
|
171
225
|
isTransient: false,
|
|
172
226
|
}))
|
|
173
227
|
.catch((error: SmtpFailure) => {
|
|
174
|
-
const classified = classifySmtpError(error);
|
|
228
|
+
const classified = classifySmtpError(error, watch.afterData);
|
|
175
229
|
if (classified) {
|
|
176
230
|
throw classified;
|
|
177
231
|
}
|
package/src/test-fake-smtp.ts
CHANGED
|
@@ -3,7 +3,8 @@ import net from "node:net";
|
|
|
3
3
|
export interface FakeSmtpOptions {
|
|
4
4
|
authResponse?: string;
|
|
5
5
|
dataFinalResponse?: string;
|
|
6
|
-
|
|
6
|
+
dropBeforeGreeting?: boolean;
|
|
7
|
+
dropAfterData?: boolean;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export interface FakeSmtp {
|
|
@@ -18,7 +19,7 @@ export const startFakeSmtp = (opts: FakeSmtpOptions = {}): Promise<FakeSmtp> =>
|
|
|
18
19
|
const authCommands: string[] = [];
|
|
19
20
|
const received: string[] = [];
|
|
20
21
|
const server = net.createServer((socket) => {
|
|
21
|
-
if (opts.
|
|
22
|
+
if (opts.dropBeforeGreeting) {
|
|
22
23
|
socket.destroy();
|
|
23
24
|
return;
|
|
24
25
|
}
|
|
@@ -30,6 +31,10 @@ export const startFakeSmtp = (opts: FakeSmtpOptions = {}): Promise<FakeSmtp> =>
|
|
|
30
31
|
received.push(line);
|
|
31
32
|
if (line.includes("\r\n.\r\n")) {
|
|
32
33
|
inData = false;
|
|
34
|
+
if (opts.dropAfterData) {
|
|
35
|
+
socket.destroy();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
33
38
|
socket.write(opts.dataFinalResponse ?? "250 2.0.0 Ok\r\n");
|
|
34
39
|
}
|
|
35
40
|
return;
|