@remit/smtp-service 0.0.1

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 ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@remit/smtp-service",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "scripts": {
7
+ "test:typecheck": "tsgo --noEmit",
8
+ "test:run": "node --env-file=../../localhost-test-unit.env --test 'src/**/*.test.ts'",
9
+ "test": "npm run test:typecheck && npm run test:run",
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
+ },
12
+ "devDependencies": {
13
+
14
+ "@remit/domain-enums": "*",
15
+ "@types/node": "*",
16
+ "@types/nodemailer": "*"
17
+ },
18
+ "dependencies": {
19
+ "nodemailer": "^9.0.3"
20
+ },
21
+ "license": "MIT",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/remit-mail/remit.git",
28
+ "directory": "packages/smtp-service"
29
+ }
30
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./message-builder.js";
2
+ export * from "./message-id.js";
3
+ export * from "./smtp-client.js";
@@ -0,0 +1,51 @@
1
+ import type { OutboxMessageItem } from "@remit/data-ports";
2
+
3
+ export interface MailMessage {
4
+ from: string;
5
+ to: string[];
6
+ cc?: string[];
7
+ bcc?: string[];
8
+ replyTo?: string;
9
+ subject?: string;
10
+ text?: string;
11
+ html?: string;
12
+ messageId: string;
13
+ inReplyTo?: string;
14
+ references?: string;
15
+ attachments?: MailAttachment[];
16
+ }
17
+
18
+ export interface MailAttachment {
19
+ filename: string;
20
+ content: Buffer;
21
+ contentType: string;
22
+ cid?: string;
23
+ contentDisposition?: "attachment" | "inline";
24
+ }
25
+
26
+ /**
27
+ * Build Nodemailer message options from OutboxMessage entity
28
+ */
29
+ export const buildMailMessage = (
30
+ outbox: OutboxMessageItem,
31
+ attachments?: MailAttachment[],
32
+ ): MailMessage => {
33
+ const from = outbox.fromName
34
+ ? `"${outbox.fromName}" <${outbox.fromAddress}>`
35
+ : outbox.fromAddress;
36
+
37
+ return {
38
+ from,
39
+ to: outbox.toAddresses,
40
+ cc: outbox.ccAddresses,
41
+ bcc: outbox.bccAddresses,
42
+ replyTo: outbox.replyToAddress,
43
+ subject: outbox.subject,
44
+ text: outbox.textBody,
45
+ html: outbox.htmlBody,
46
+ messageId: `<${outbox.messageIdValue}>`,
47
+ inReplyTo: outbox.inReplyTo ? `<${outbox.inReplyTo}>` : undefined,
48
+ references: outbox.references?.map((r) => `<${r}>`).join(" "),
49
+ attachments,
50
+ };
51
+ };
@@ -0,0 +1,51 @@
1
+ import assert from "node:assert";
2
+ import { describe, it } from "node:test";
3
+ import { extractDomain, generateMessageId } from "./message-id.js";
4
+
5
+ describe("Message-ID generation", () => {
6
+ it("generates unique message IDs", () => {
7
+ const id1 = generateMessageId("example.com");
8
+ const id2 = generateMessageId("example.com");
9
+ assert.notStrictEqual(id1, id2);
10
+ });
11
+
12
+ it("includes domain in message ID", () => {
13
+ const id = generateMessageId("test.example.com");
14
+ assert.ok(id.endsWith("@test.example.com"));
15
+ });
16
+
17
+ it("contains timestamp and random hex", () => {
18
+ const id = generateMessageId("example.com");
19
+ const parts = id.split("@")[0].split(".");
20
+ assert.strictEqual(parts.length, 2);
21
+ // First part is timestamp (numeric)
22
+ assert.ok(/^\d+$/.test(parts[0]));
23
+ // Second part is hex string
24
+ assert.ok(/^[0-9a-f]+$/.test(parts[1]));
25
+ });
26
+ });
27
+
28
+ describe("extractDomain", () => {
29
+ it("extracts domain from email", () => {
30
+ assert.strictEqual(extractDomain("user@example.com"), "example.com");
31
+ });
32
+
33
+ it("handles email with + alias", () => {
34
+ assert.strictEqual(extractDomain("user+tag@example.com"), "example.com");
35
+ });
36
+
37
+ it("handles subdomain", () => {
38
+ assert.strictEqual(
39
+ extractDomain("user@mail.example.com"),
40
+ "mail.example.com",
41
+ );
42
+ });
43
+
44
+ it("uses last @ for edge cases", () => {
45
+ assert.strictEqual(extractDomain("user@name@example.com"), "example.com");
46
+ });
47
+
48
+ it("throws on invalid email", () => {
49
+ assert.throws(() => extractDomain("invalid"), /Invalid email/);
50
+ });
51
+ });
@@ -0,0 +1,22 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ /**
4
+ * Generate RFC 2822 compliant Message-ID
5
+ * Format: <timestamp.random@domain>
6
+ */
7
+ export const generateMessageId = (domain: string): string => {
8
+ const timestamp = Date.now();
9
+ const random = randomBytes(8).toString("hex");
10
+ return `${timestamp}.${random}@${domain}`;
11
+ };
12
+
13
+ /**
14
+ * Extract domain from email address for Message-ID generation
15
+ */
16
+ export const extractDomain = (email: string): string => {
17
+ const atIndex = email.lastIndexOf("@");
18
+ if (atIndex === -1) {
19
+ throw new Error(`Invalid email address: ${email}`);
20
+ }
21
+ return email.slice(atIndex + 1);
22
+ };
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Integration tests for SMTP client using mokapi.
3
+ *
4
+ * These tests require mokapi to be running:
5
+ * npm run start:mokapi
6
+ *
7
+ * Run with:
8
+ * npm run test:integ -w packages/smtp-service
9
+ *
10
+ * Note: mokapi SMTP server runs on port 2525 without TLS.
11
+ * Sent emails are delivered to the recipient's IMAP mailbox.
12
+ */
13
+
14
+ import assert from "node:assert";
15
+ import { describe, test } from "node:test";
16
+ import type { MailMessage } from "./message-builder.js";
17
+ import { generateMessageId } from "./message-id.js";
18
+ import { type SmtpConfig, sendMail } from "./smtp-client.js";
19
+
20
+ const MOKAPI_SMTP_CONFIG: SmtpConfig = {
21
+ host: "localhost",
22
+ port: 2525,
23
+ secure: false,
24
+ user: "alice@mokapi.io",
25
+ credentials: { kind: "password", password: "alice123" },
26
+ tls: {
27
+ rejectUnauthorized: false, // Accept mokapi's self-signed cert
28
+ },
29
+ };
30
+
31
+ /**
32
+ * Generate a unique message ID for testing
33
+ */
34
+ const createTestMessageId = (): string => {
35
+ return `<${generateMessageId("mokapi.io")}>`;
36
+ };
37
+
38
+ /**
39
+ * Create a basic test message
40
+ */
41
+ const createTestMessage = (
42
+ overrides: Partial<MailMessage> = {},
43
+ ): MailMessage => {
44
+ return {
45
+ from: "alice@mokapi.io",
46
+ to: ["bob@mokapi.io"],
47
+ subject: `Test message ${Date.now()}`,
48
+ text: "This is a test message body.",
49
+ messageId: createTestMessageId(),
50
+ ...overrides,
51
+ };
52
+ };
53
+
54
+ describe(
55
+ "SMTP client integration tests",
56
+ {
57
+ skip: !process.env.RUN_INTEG_TESTS,
58
+ },
59
+ () => {
60
+ describe("sendMail", () => {
61
+ test("sends a simple text message", async () => {
62
+ const message = createTestMessage({
63
+ subject: `Simple text test ${Date.now()}`,
64
+ text: "Hello from the SMTP integration test!",
65
+ });
66
+
67
+ const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
68
+
69
+ assert.equal(result.success, true, "Send should succeed");
70
+ assert.equal(result.isTransient, false, "Should not be transient");
71
+ assert.ok(result.messageId, "Should return a message ID");
72
+ assert.ok(result.response, "Should return an SMTP response");
73
+ });
74
+
75
+ test("sends an HTML message", async () => {
76
+ const message = createTestMessage({
77
+ subject: `HTML test ${Date.now()}`,
78
+ text: "Plain text fallback",
79
+ html: "<html><body><h1>Hello!</h1><p>This is an <strong>HTML</strong> message.</p></body></html>",
80
+ });
81
+
82
+ const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
83
+
84
+ assert.equal(result.success, true, "Send should succeed");
85
+ assert.ok(result.messageId, "Should return a message ID");
86
+ });
87
+
88
+ test("sends a message with CC recipients", async () => {
89
+ const message = createTestMessage({
90
+ subject: `CC test ${Date.now()}`,
91
+ to: ["bob@mokapi.io"],
92
+ cc: ["alice@mokapi.io"],
93
+ text: "Message with CC recipient",
94
+ });
95
+
96
+ const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
97
+
98
+ assert.equal(result.success, true, "Send should succeed");
99
+ assert.ok(result.messageId, "Should return a message ID");
100
+ });
101
+
102
+ test("sends a message with BCC recipients", async () => {
103
+ const message = createTestMessage({
104
+ subject: `BCC test ${Date.now()}`,
105
+ to: ["bob@mokapi.io"],
106
+ bcc: ["alice@mokapi.io"],
107
+ text: "Message with BCC recipient",
108
+ });
109
+
110
+ const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
111
+
112
+ assert.equal(result.success, true, "Send should succeed");
113
+ assert.ok(result.messageId, "Should return a message ID");
114
+ });
115
+
116
+ test("sends a message with multiple recipients", async () => {
117
+ const message = createTestMessage({
118
+ subject: `Multiple recipients test ${Date.now()}`,
119
+ to: ["bob@mokapi.io", "alice@mokapi.io"],
120
+ text: "Message to multiple recipients",
121
+ });
122
+
123
+ const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
124
+
125
+ assert.equal(result.success, true, "Send should succeed");
126
+ assert.ok(result.messageId, "Should return a message ID");
127
+ });
128
+
129
+ test("sends a reply with In-Reply-To header", async () => {
130
+ const originalMessageId = "<original-message-123@mokapi.io>";
131
+ const message = createTestMessage({
132
+ subject: `Re: Original subject ${Date.now()}`,
133
+ text: "This is a reply to your message.",
134
+ inReplyTo: originalMessageId,
135
+ });
136
+
137
+ const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
138
+
139
+ assert.equal(result.success, true, "Send should succeed");
140
+ assert.ok(result.messageId, "Should return a message ID");
141
+ });
142
+
143
+ test("sends a reply with References header", async () => {
144
+ const references = [
145
+ "<msg-1@mokapi.io>",
146
+ "<msg-2@mokapi.io>",
147
+ "<msg-3@mokapi.io>",
148
+ ].join(" ");
149
+
150
+ const message = createTestMessage({
151
+ subject: `Re: Thread subject ${Date.now()}`,
152
+ text: "This is a reply in a thread.",
153
+ inReplyTo: "<msg-3@mokapi.io>",
154
+ references,
155
+ });
156
+
157
+ const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
158
+
159
+ assert.equal(result.success, true, "Send should succeed");
160
+ assert.ok(result.messageId, "Should return a message ID");
161
+ });
162
+
163
+ test("sends a message with Reply-To address", async () => {
164
+ const message = createTestMessage({
165
+ subject: `Reply-To test ${Date.now()}`,
166
+ text: "Please reply to a different address.",
167
+ replyTo: "noreply@mokapi.io",
168
+ });
169
+
170
+ const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
171
+
172
+ assert.equal(result.success, true, "Send should succeed");
173
+ assert.ok(result.messageId, "Should return a message ID");
174
+ });
175
+
176
+ test("sends a message with formatted From name", async () => {
177
+ const message = createTestMessage({
178
+ from: '"Alice Test" <alice@mokapi.io>',
179
+ subject: `From name test ${Date.now()}`,
180
+ text: "Message with formatted From name",
181
+ });
182
+
183
+ const result = await sendMail(MOKAPI_SMTP_CONFIG, message);
184
+
185
+ assert.equal(result.success, true, "Send should succeed");
186
+ assert.ok(result.messageId, "Should return a message ID");
187
+ });
188
+
189
+ test("handles connection failure gracefully", async () => {
190
+ const badConfig: SmtpConfig = {
191
+ host: "localhost",
192
+ port: 9999, // Non-existent port
193
+ secure: false,
194
+ user: "alice@mokapi.io",
195
+ credentials: { kind: "password", password: "alice123" },
196
+ connectionTimeout: 1000, // Short timeout for tests
197
+ };
198
+
199
+ const message = createTestMessage();
200
+ const result = await sendMail(badConfig, message);
201
+
202
+ assert.equal(result.success, false, "Send should fail");
203
+ assert.ok(result.error, "Should return an error");
204
+ // Connection errors are typically transient
205
+ assert.equal(
206
+ result.isTransient,
207
+ false,
208
+ "Connection errors without SMTP code are not transient",
209
+ );
210
+ });
211
+
212
+ test("handles authentication failure", async () => {
213
+ const badAuthConfig: SmtpConfig = {
214
+ host: "localhost",
215
+ port: 2525,
216
+ secure: false,
217
+ user: "alice@mokapi.io",
218
+ credentials: { kind: "password", password: "wrong-password" },
219
+ tls: {
220
+ rejectUnauthorized: false,
221
+ },
222
+ connectionTimeout: 5000,
223
+ };
224
+
225
+ const message = createTestMessage();
226
+ const result = await sendMail(badAuthConfig, message);
227
+
228
+ // mokapi may or may not enforce authentication
229
+ // If it does, this should fail; if not, it will succeed
230
+ if (!result.success) {
231
+ assert.ok(result.error, "Should return an error");
232
+ // 5xx errors are permanent (not transient)
233
+ if (result.smtpCode && result.smtpCode >= 500) {
234
+ assert.equal(
235
+ result.isTransient,
236
+ false,
237
+ "Auth failure should be permanent",
238
+ );
239
+ }
240
+ }
241
+ });
242
+ });
243
+ },
244
+ );
@@ -0,0 +1,194 @@
1
+ import nodemailer from "nodemailer";
2
+ import type { MailMessage } from "./message-builder.js";
3
+
4
+ /**
5
+ * Discriminated union of SMTP authentication credentials.
6
+ *
7
+ * Mirrors MailCredentials in remit-mailbox-service. Defined here to avoid a
8
+ * cross-package dependency — smtp-service does not depend on mailbox-service.
9
+ */
10
+ export type SmtpCredentials =
11
+ | { kind: "password"; password: string }
12
+ | { kind: "accessToken"; accessToken: string };
13
+
14
+ /**
15
+ * Classification of SMTP connection / send errors.
16
+ */
17
+ export type SmtpErrorKind = "auth" | "network";
18
+
19
+ /**
20
+ * Typed error for SMTP authentication and network failures.
21
+ *
22
+ * IMPORTANT: access tokens must NEVER appear in error messages.
23
+ */
24
+ export class SmtpConnectionError extends Error {
25
+ readonly kind: SmtpErrorKind;
26
+
27
+ constructor(kind: SmtpErrorKind, message: string, cause?: unknown) {
28
+ super(message, { cause });
29
+ this.name = "SmtpConnectionError";
30
+ this.kind = kind;
31
+ }
32
+ }
33
+
34
+ /** Password-based SMTP auth (AUTH PLAIN/LOGIN) */
35
+ export interface SmtpAuthPassword {
36
+ type?: undefined;
37
+ user: string;
38
+ pass: string;
39
+ }
40
+
41
+ /** OAuth2 SMTP auth (XOAUTH2 / OAUTHBEARER) */
42
+ export interface SmtpAuthOAuth2 {
43
+ type: "OAUTH2";
44
+ user: string;
45
+ accessToken: string;
46
+ }
47
+
48
+ export type SmtpAuth = SmtpAuthPassword | SmtpAuthOAuth2;
49
+
50
+ export interface SmtpConfig {
51
+ host: string;
52
+ port: number;
53
+ secure: boolean; // true for TLS (465), false for STARTTLS (587)
54
+ credentials: SmtpCredentials;
55
+ user: string;
56
+ tls?: {
57
+ rejectUnauthorized?: boolean; // false to accept self-signed certs (testing only)
58
+ };
59
+ connectionTimeout?: number; // milliseconds, default 30000
60
+ }
61
+
62
+ export interface SendResult {
63
+ success: boolean;
64
+ messageId?: string;
65
+ response?: string;
66
+ error?: Error;
67
+ smtpCode?: number;
68
+ isTransient: boolean;
69
+ }
70
+
71
+ /**
72
+ * Build nodemailer auth from SmtpCredentials.
73
+ * IMPORTANT: never include accessToken values in error messages.
74
+ */
75
+ const buildSmtpAuth = (
76
+ user: string,
77
+ credentials: SmtpCredentials,
78
+ ):
79
+ | { user: string; pass: string }
80
+ | { type: "OAuth2"; user: string; accessToken: string } => {
81
+ if (credentials.kind === "password") {
82
+ return { user, pass: credentials.password };
83
+ }
84
+ if (credentials.kind === "accessToken") {
85
+ return {
86
+ type: "OAuth2" as const,
87
+ user,
88
+ accessToken: credentials.accessToken,
89
+ };
90
+ }
91
+ // Exhaustiveness check — fails to compile if a new credential kind is added
92
+ // without handling it here.
93
+ const _exhaustive: never = credentials;
94
+ throw new Error(`Unknown credential kind: ${JSON.stringify(_exhaustive)}`);
95
+ };
96
+
97
+ /**
98
+ * Send email via SMTP using Nodemailer
99
+ */
100
+ export const sendMail = async (
101
+ config: SmtpConfig,
102
+ message: MailMessage,
103
+ ): Promise<SendResult> => {
104
+ const timeout = config.connectionTimeout ?? 30_000;
105
+ const transporter = nodemailer.createTransport({
106
+ host: config.host,
107
+ port: config.port,
108
+ secure: config.secure,
109
+ auth: buildSmtpAuth(config.user, config.credentials),
110
+ tls: config.tls,
111
+ connectionTimeout: timeout,
112
+ greetingTimeout: timeout,
113
+ socketTimeout: 300_000,
114
+ });
115
+
116
+ return transporter
117
+ .sendMail({
118
+ from: message.from,
119
+ to: message.to,
120
+ cc: message.cc,
121
+ bcc: message.bcc,
122
+ replyTo: message.replyTo,
123
+ subject: message.subject,
124
+ text: message.text,
125
+ html: message.html,
126
+ messageId: message.messageId,
127
+ inReplyTo: message.inReplyTo,
128
+ references: message.references,
129
+ attachments: message.attachments?.map((a) => ({
130
+ filename: a.filename,
131
+ content: a.content as Buffer,
132
+ contentType: a.contentType,
133
+ cid: a.cid,
134
+ contentDisposition: a.contentDisposition,
135
+ })),
136
+ })
137
+ .then((info) => ({
138
+ success: true,
139
+ messageId: info.messageId,
140
+ response: info.response,
141
+ isTransient: false,
142
+ }))
143
+ .catch(
144
+ (
145
+ error: Error & {
146
+ responseCode?: number;
147
+ code?: string;
148
+ command?: string;
149
+ },
150
+ ) => {
151
+ const smtpCode = error.responseCode;
152
+
153
+ // Classify auth errors — EAUTH or 5xx on AUTH command
154
+ if (
155
+ error.code === "EAUTH" ||
156
+ (smtpCode !== undefined &&
157
+ smtpCode >= 500 &&
158
+ error.command === "AUTH")
159
+ ) {
160
+ throw new SmtpConnectionError(
161
+ "auth",
162
+ "SMTP authentication failed",
163
+ error,
164
+ );
165
+ }
166
+
167
+ // Classify network errors
168
+ if (
169
+ error.code === "ECONNREFUSED" ||
170
+ error.code === "ETIMEDOUT" ||
171
+ error.code === "ENOTFOUND" ||
172
+ error.code === "ECONNRESET" ||
173
+ error.code === "EHOSTUNREACH"
174
+ ) {
175
+ throw new SmtpConnectionError(
176
+ "network",
177
+ `SMTP connection failed: ${error.code}`,
178
+ error,
179
+ );
180
+ }
181
+
182
+ // 4xx = transient (retry), 5xx = permanent (no retry)
183
+ const isTransient =
184
+ smtpCode !== undefined && smtpCode >= 400 && smtpCode < 500;
185
+ return {
186
+ success: false,
187
+ error,
188
+ smtpCode,
189
+ isTransient,
190
+ };
191
+ },
192
+ )
193
+ .finally(() => transporter.close());
194
+ };
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Unit tests for SmtpCredentials union and SmtpConnectionError.
3
+ *
4
+ * Covers:
5
+ * 1. Auth object construction — nodemailer auth shape for each credential kind
6
+ * 2. Exhaustiveness — TypeScript catches missing cases via `never`
7
+ * 3. Token-leak assertion — accessToken never in serialized error
8
+ * 4. Error kind classification
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import { inspect } from "node:util";
14
+ import { SmtpConnectionError, type SmtpCredentials } from "./smtp-client.js";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helper: mirrors buildSmtpAuth in smtp-client.ts
18
+ // ---------------------------------------------------------------------------
19
+
20
+ type SmtpAuth =
21
+ | { user: string; pass: string }
22
+ | { type: "OAuth2"; user: string; accessToken: string };
23
+
24
+ const buildSmtpAuth = (
25
+ user: string,
26
+ credentials: SmtpCredentials,
27
+ ): SmtpAuth => {
28
+ if (credentials.kind === "password") {
29
+ return { user, pass: credentials.password };
30
+ }
31
+ if (credentials.kind === "accessToken") {
32
+ return {
33
+ type: "OAuth2" as const,
34
+ user,
35
+ accessToken: credentials.accessToken,
36
+ };
37
+ }
38
+ // Exhaustiveness check — TypeScript will error here if a new union member
39
+ // is added without updating this function.
40
+ const _exhaustive: never = credentials;
41
+ throw new Error(`Unknown credential kind: ${JSON.stringify(_exhaustive)}`);
42
+ };
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Tests
46
+ // ---------------------------------------------------------------------------
47
+
48
+ describe("SmtpCredentials — auth shape", () => {
49
+ it("password kind → { user, pass }", () => {
50
+ const creds: SmtpCredentials = { kind: "password", password: "s3cr3t" };
51
+ const auth = buildSmtpAuth("alice@example.com", creds);
52
+
53
+ assert.deepEqual(auth, { user: "alice@example.com", pass: "s3cr3t" });
54
+ assert.ok(!("type" in auth), "no OAuth2 type for password auth");
55
+ assert.ok(!("accessToken" in auth), "no accessToken for password auth");
56
+ });
57
+
58
+ it("accessToken kind → { type: 'OAuth2', user, accessToken }", () => {
59
+ const token = "ya29.super-secret-token";
60
+ const creds: SmtpCredentials = { kind: "accessToken", accessToken: token };
61
+ const auth = buildSmtpAuth("alice@example.com", creds);
62
+
63
+ assert.deepEqual(auth, {
64
+ type: "OAuth2",
65
+ user: "alice@example.com",
66
+ accessToken: token,
67
+ });
68
+ assert.ok(!("pass" in auth), "no pass for OAuth2 auth");
69
+ });
70
+ });
71
+
72
+ describe("SmtpConnectionError", () => {
73
+ it("stores kind='auth'", () => {
74
+ const err = new SmtpConnectionError("auth", "SMTP authentication failed");
75
+ assert.equal(err.kind, "auth");
76
+ assert.equal(err.name, "SmtpConnectionError");
77
+ assert.equal(err.message, "SMTP authentication failed");
78
+ });
79
+
80
+ it("stores kind='network'", () => {
81
+ const err = new SmtpConnectionError(
82
+ "network",
83
+ "SMTP connection failed: ECONNREFUSED",
84
+ );
85
+ assert.equal(err.kind, "network");
86
+ });
87
+
88
+ it("is instanceof Error", () => {
89
+ const err = new SmtpConnectionError("auth", "test");
90
+ assert.ok(err instanceof Error);
91
+ assert.ok(err instanceof SmtpConnectionError);
92
+ });
93
+
94
+ it("token-leak: accessToken must NOT appear in inspected SmtpConnectionError (incl. cause)", () => {
95
+ const secretToken = "ya29.A0ARrdaM_very_secret_smtp_token_67890";
96
+
97
+ // Reproduce what sendMail() does: classify a nodemailer EAUTH failure and
98
+ // attach the underlying error as `cause`. A realistic nodemailer auth
99
+ // error reports the failure without echoing the access token.
100
+ const underlyingError = Object.assign(
101
+ new Error("Invalid login: 535 5.7.8 Authentication credentials invalid"),
102
+ { code: "EAUTH", responseCode: 535, command: "AUTH" },
103
+ );
104
+ const err = new SmtpConnectionError(
105
+ "auth",
106
+ "SMTP authentication failed",
107
+ underlyingError,
108
+ );
109
+
110
+ // util.inspect walks the full error including the cause chain, so this
111
+ // proves no token leaks through name/message/stack OR cause.
112
+ const serialized = inspect(err, { depth: null });
113
+
114
+ assert.ok(
115
+ !serialized.includes(secretToken),
116
+ `SmtpConnectionError must not contain the access token — got: ${serialized.slice(0, 200)}`,
117
+ );
118
+ });
119
+
120
+ it("token-leak: accessToken in cause does NOT bubble into error.message", () => {
121
+ const secretToken = "ya29.A0ARrdaM_very_secret_smtp_token_CAUSE";
122
+ const underlyingError = new Error(`AUTH PLAIN failed token=${secretToken}`);
123
+
124
+ const err = new SmtpConnectionError(
125
+ "auth",
126
+ "SMTP authentication failed",
127
+ underlyingError,
128
+ );
129
+
130
+ assert.ok(
131
+ !err.message.includes(secretToken),
132
+ "error.message must not contain the access token",
133
+ );
134
+ });
135
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src/**/*"]
8
+ }