@remit/smtp-service 1.0.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/smtp-service",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "exports": {
@@ -167,14 +167,28 @@ describe("sendMail", () => {
167
167
  assert.equal(result.isTransient, true);
168
168
  });
169
169
 
170
- it("returns a non-transient failure when the socket errors without an SMTP code", async () => {
171
- const server = await spawn({ dropAfterGreeting: true });
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.equal(result.success, false);
175
- assert.equal(result.isTransient, false);
176
- assert.equal(result.smtpCode, undefined);
177
- assert.ok(result.error);
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
 
@@ -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(kind: SmtpErrorKind, message: string, cause?: unknown) {
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
- "ECONNREFUSED",
114
+ "ECONNECTION",
115
+ "ESOCKET",
102
116
  "ETIMEDOUT",
103
- "ENOTFOUND",
104
- "ECONNRESET",
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 = (config: SmtpConfig, socketTimeout: number) => {
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 = (error: SmtpFailure): SmtpConnectionError | null => {
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 transporter = transporterFor(config, 300_000);
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
  }
@@ -3,7 +3,8 @@ import net from "node:net";
3
3
  export interface FakeSmtpOptions {
4
4
  authResponse?: string;
5
5
  dataFinalResponse?: string;
6
- dropAfterGreeting?: boolean;
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.dropAfterGreeting) {
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;