@remit/mailbox-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.
Files changed (124) hide show
  1. package/README.md +147 -0
  2. package/package.json +65 -0
  3. package/src/account-credentials.ts +128 -0
  4. package/src/adversarial-mime.e2e.test.ts +311 -0
  5. package/src/attribute-mapper.test.ts +78 -0
  6. package/src/attribute-mapper.ts +147 -0
  7. package/src/body-part-mapper.property.test.ts +354 -0
  8. package/src/body-part-mapper.ts +360 -0
  9. package/src/body-sync-queue.test.ts +124 -0
  10. package/src/body-sync-queue.ts +131 -0
  11. package/src/body-sync-terminal.test.ts +265 -0
  12. package/src/body-sync-terminal.ts +172 -0
  13. package/src/body-sync.ts +1202 -0
  14. package/src/connection-factory.ts +102 -0
  15. package/src/connection-test.ts +208 -0
  16. package/src/credentials.test.ts +181 -0
  17. package/src/filters/match.test.ts +181 -0
  18. package/src/filters/match.ts +143 -0
  19. package/src/filters/pipeline.ts +243 -0
  20. package/src/flag-push-terminal.test.ts +156 -0
  21. package/src/flag-push-terminal.ts +106 -0
  22. package/src/flag-push.test.ts +408 -0
  23. package/src/flag-push.ts +198 -0
  24. package/src/flag-queue.ts +500 -0
  25. package/src/heuristics/classifyByHeaders.test.ts +578 -0
  26. package/src/heuristics/classifyByHeaders.ts +369 -0
  27. package/src/heuristics/classifyPlacement.ts +96 -0
  28. package/src/heuristics/socialDomains.ts +23 -0
  29. package/src/heuristics/transactionalDomains.ts +26 -0
  30. package/src/imapflow-connect-retry.test.ts +108 -0
  31. package/src/imapflow-connection.e2e.test.ts +355 -0
  32. package/src/imapflow-connection.integ.test.ts +327 -0
  33. package/src/imapflow-connection.test.ts +161 -0
  34. package/src/imapflow-connection.ts +1374 -0
  35. package/src/index.ts +214 -0
  36. package/src/mailbox-cursor-rebuild.test.ts +171 -0
  37. package/src/mailbox-cursor-rebuild.ts +146 -0
  38. package/src/mailbox-cursor.test.ts +249 -0
  39. package/src/mailbox-cursor.ts +177 -0
  40. package/src/mailbox-management.test.ts +103 -0
  41. package/src/mailbox-management.ts +235 -0
  42. package/src/mailbox-queue.ts +258 -0
  43. package/src/mailbox-sync.test.ts +153 -0
  44. package/src/mailbox-sync.ts +590 -0
  45. package/src/message-move.ts +825 -0
  46. package/src/message-parser.ts +26 -0
  47. package/src/message-sync.ts +1021 -0
  48. package/src/mime-walker.test.ts +311 -0
  49. package/src/mime-walker.ts +305 -0
  50. package/src/outbox-queue.ts +294 -0
  51. package/src/pass-through-unit-of-work.ts +17 -0
  52. package/src/placement-move-terminal.test.ts +179 -0
  53. package/src/placement-move-terminal.ts +104 -0
  54. package/src/placement-move.ts +224 -0
  55. package/src/snippet.test.ts +213 -0
  56. package/src/snippet.ts +152 -0
  57. package/src/stale-message-reconcile.test.ts +82 -0
  58. package/src/stale-message-reconcile.ts +55 -0
  59. package/src/test-helpers/isolated-mailbox.ts +44 -0
  60. package/src/test-helpers/mailfuzz-connection.ts +27 -0
  61. package/src/test-helpers/mime-tree-generator.ts +461 -0
  62. package/src/text/normalizer.test.ts +223 -0
  63. package/src/text/normalizer.ts +140 -0
  64. package/src/types/mailparser-augment.d.ts +15 -0
  65. package/src/types/natural-porter-stemmer.d.ts +50 -0
  66. package/src/types/stopword.d.ts +69 -0
  67. package/src/types.ts +349 -0
  68. package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
  69. package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
  70. package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
  71. package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
  72. package/test/fixtures/mime/02-flat-text-html.eml +10 -0
  73. package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
  74. package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
  75. package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
  76. package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
  77. package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
  78. package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
  79. package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
  80. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
  81. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
  82. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
  83. package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
  84. package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
  85. package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
  86. package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
  87. package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
  88. package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
  89. package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
  90. package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
  91. package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
  92. package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
  93. package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
  94. package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
  95. package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
  96. package/test/fixtures/mime/10-calendar-invite.eml +33 -0
  97. package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
  98. package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
  99. package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
  100. package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
  101. package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
  102. package/test/fixtures/mime/12-base64-binary.eml +20 -0
  103. package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
  104. package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
  105. package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
  106. package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
  107. package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
  108. package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
  109. package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
  110. package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
  111. package/test/fixtures/mime/15-empty-text-part.eml +18 -0
  112. package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
  113. package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
  114. package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
  115. package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
  124. package/tsconfig.json +8 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Connection factory for IMAP implementations
3
+ *
4
+ * Uses ImapFlow for all IMAP connections.
5
+ */
6
+
7
+ import {
8
+ createImapFlowConnectionFromAccount,
9
+ createImapFlowConnectionWithCredentials,
10
+ ImapFlowConnection,
11
+ } from "./imapflow-connection.js";
12
+ import type {
13
+ IImapConnection,
14
+ ImapConnectionConfig,
15
+ MailCredentials,
16
+ } from "./types.js";
17
+
18
+ /**
19
+ * Create an IMAP connection
20
+ */
21
+ export const createConnection = (
22
+ config: ImapConnectionConfig,
23
+ ): IImapConnection => {
24
+ return new ImapFlowConnection(config);
25
+ };
26
+
27
+ /**
28
+ * Create an IMAP connection from account data using password credentials.
29
+ */
30
+ export const createConnectionFromAccount = (
31
+ account: {
32
+ imapHost: string;
33
+ imapPort: number;
34
+ imapTls: boolean;
35
+ username: string;
36
+ },
37
+ password: string,
38
+ ): IImapConnection => {
39
+ return createImapFlowConnectionFromAccount(account, password);
40
+ };
41
+
42
+ /**
43
+ * Create an IMAP connection from account data using a MailCredentials union.
44
+ * Use this instead of createConnectionFromAccount for OAuth accounts.
45
+ */
46
+ export const createConnectionWithCredentials = (
47
+ account: {
48
+ imapHost: string;
49
+ imapPort: number;
50
+ imapTls: boolean;
51
+ username: string;
52
+ },
53
+ credentials: MailCredentials,
54
+ ): IImapConnection => {
55
+ return createImapFlowConnectionWithCredentials(account, credentials);
56
+ };
57
+
58
+ /**
59
+ * Managed connection factory that caches and reuses a single connection.
60
+ *
61
+ * The factory handles connection lifecycle - callers should not disconnect.
62
+ * Call close() on the factory to disconnect when done.
63
+ */
64
+ export interface ManagedConnectionFactory {
65
+ /** Get the cached connection (creates on first call) */
66
+ getConnection(): IImapConnection;
67
+ /** Disconnect and cleanup */
68
+ close(): Promise<void>;
69
+ }
70
+
71
+ /**
72
+ * Create a managed connection factory that caches a single connection.
73
+ *
74
+ * @example
75
+ * ```typescript
76
+ * const factory = createManagedConnectionFactory(config);
77
+ * const conn = factory.getConnection();
78
+ * await conn.connect();
79
+ * // ... reuse conn via factory.getConnection() ...
80
+ * await factory.close(); // Disconnect when done
81
+ * ```
82
+ */
83
+ export const createManagedConnectionFactory = (
84
+ config: ImapConnectionConfig,
85
+ ): ManagedConnectionFactory => {
86
+ let connection: ImapFlowConnection | null = null;
87
+
88
+ return {
89
+ getConnection: () => {
90
+ if (!connection) {
91
+ connection = new ImapFlowConnection(config);
92
+ }
93
+ return connection;
94
+ },
95
+ close: async () => {
96
+ if (connection) {
97
+ await connection.disconnect();
98
+ connection = null;
99
+ }
100
+ },
101
+ };
102
+ };
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Connection testing utilities for IMAP and SMTP servers
3
+ *
4
+ * Used to validate credentials before saving account configuration.
5
+ */
6
+
7
+ import { createConnection as createNetConnection } from "node:net";
8
+ import { connect as tlsConnect } from "node:tls";
9
+ import { createTransport } from "nodemailer";
10
+ import type { MailCredentials } from "./types.js";
11
+ import { MailConnectionError } from "./types.js";
12
+
13
+ export interface TestResult {
14
+ success: boolean;
15
+ error?: string;
16
+ }
17
+
18
+ export interface ImapTestConfig {
19
+ host: string;
20
+ port: number;
21
+ secure: boolean;
22
+ startTls?: boolean;
23
+ credentials: MailCredentials;
24
+ user: string;
25
+ }
26
+
27
+ export interface SmtpTestConfig {
28
+ host: string;
29
+ port: number;
30
+ secure: boolean;
31
+ credentials: MailCredentials;
32
+ user: string;
33
+ }
34
+
35
+ // TODO(#468): Wire OAuth testConnection once authType/oauthRefreshTokenHash are
36
+ // available on Account. For OAuth accounts, mint a token via MailOAuthService
37
+ // before calling testImapConnection / testSmtpConnection, and map
38
+ // RefreshTokenError(reauth-required) to MailConnectionError("auth", ...).
39
+
40
+ const TEST_TIMEOUT_MS = 12_000;
41
+
42
+ /**
43
+ * Build nodemailer auth object from credentials.
44
+ */
45
+ const buildSmtpAuth = (
46
+ user: string,
47
+ credentials: MailCredentials,
48
+ ):
49
+ | { user: string; pass: string }
50
+ | { type: "OAuth2"; user: string; accessToken: string } => {
51
+ if (credentials.kind === "password") {
52
+ return { user, pass: credentials.password };
53
+ }
54
+ return {
55
+ type: "OAuth2" as const,
56
+ user,
57
+ accessToken: credentials.accessToken,
58
+ };
59
+ };
60
+
61
+ /**
62
+ * Test IMAP connection with provided credentials.
63
+ *
64
+ * Uses a raw socket to send AUTH PLAIN directly — bypasses the CAPABILITY
65
+ * round-trip that IMAP clients typically perform first, avoiding deadlocks
66
+ * with servers that pipeline their tagged responses.
67
+ *
68
+ * Supports plaintext, direct TLS (secure: true), and STARTTLS (startTls: true).
69
+ */
70
+ export const testImapConnection = async (
71
+ config: ImapTestConfig,
72
+ ): Promise<TestResult> => {
73
+ if (config.credentials.kind !== "password") {
74
+ return { success: false, error: "OAuth not supported for connection test" };
75
+ }
76
+
77
+ const { user, credentials } = config;
78
+ const authPlain = Buffer.from(
79
+ `\x00${user}\x00${credentials.password}`,
80
+ ).toString("base64");
81
+
82
+ return new Promise((resolve) => {
83
+ let settled = false;
84
+ const done = (result: TestResult) => {
85
+ if (settled) return;
86
+ settled = true;
87
+ clearTimeout(timeoutId);
88
+ resolve(result);
89
+ };
90
+
91
+ const timeoutId = setTimeout(
92
+ () => done({ success: false, error: "Connection timed out" }),
93
+ TEST_TIMEOUT_MS,
94
+ );
95
+
96
+ let buf = "";
97
+ let phase: "greeting" | "starttls" | "tls-upgrade" | "auth" | "done" =
98
+ "greeting";
99
+ // biome-ignore lint/suspicious/noExplicitAny: socket type varies between net.Socket and tls.TLSSocket
100
+ let sock: any;
101
+
102
+ const onData = (chunk: Buffer) => {
103
+ buf += chunk.toString();
104
+ const lines = buf.split("\r\n");
105
+ buf = lines.pop() ?? "";
106
+
107
+ for (const line of lines) {
108
+ if (phase === "greeting" && /^\* OK/i.test(line)) {
109
+ if (config.startTls) {
110
+ phase = "starttls";
111
+ sock.write("A001 STARTTLS\r\n");
112
+ } else {
113
+ phase = "auth";
114
+ sock.write(`A001 AUTHENTICATE PLAIN ${authPlain}\r\n`);
115
+ }
116
+ } else if (phase === "starttls" && /^A001 OK/i.test(line)) {
117
+ phase = "tls-upgrade";
118
+ // Upgrade socket to TLS in place
119
+ const tlsSock = tlsConnect({
120
+ socket: sock,
121
+ host: config.host,
122
+ rejectUnauthorized: false,
123
+ });
124
+ tlsSock.on("data", onData);
125
+ tlsSock.on("error", onError);
126
+ tlsSock.once("secureConnect", () => {
127
+ phase = "auth";
128
+ sock = tlsSock;
129
+ sock.write(`A002 AUTHENTICATE PLAIN ${authPlain}\r\n`);
130
+ });
131
+ } else if (
132
+ phase === "auth" &&
133
+ (/^A00[12] OK/i.test(line) || /^A001 OK/i.test(line))
134
+ ) {
135
+ phase = "done";
136
+ sock.write("A099 LOGOUT\r\n");
137
+ done({ success: true });
138
+ } else if (
139
+ phase === "auth" &&
140
+ (/^A00[12] NO/i.test(line) || /^A00[12] BAD/i.test(line))
141
+ ) {
142
+ phase = "done";
143
+ sock.destroy();
144
+ done({ success: false, error: "Authentication failed" });
145
+ }
146
+ }
147
+ };
148
+
149
+ const onError = (err: Error) => {
150
+ done({ success: false, error: err.message });
151
+ };
152
+
153
+ if (config.secure) {
154
+ sock = tlsConnect({
155
+ host: config.host,
156
+ port: config.port,
157
+ rejectUnauthorized: false,
158
+ });
159
+ } else {
160
+ sock = createNetConnection({ host: config.host, port: config.port });
161
+ }
162
+
163
+ sock.on("data", onData);
164
+ sock.on("error", onError);
165
+ });
166
+ };
167
+
168
+ /**
169
+ * Test SMTP connection with provided credentials
170
+ *
171
+ * Uses nodemailer's verify() method to test authentication.
172
+ */
173
+ export const testSmtpConnection = async (
174
+ config: SmtpTestConfig,
175
+ ): Promise<TestResult> => {
176
+ const transport = createTransport({
177
+ host: config.host,
178
+ port: config.port,
179
+ secure: config.secure,
180
+ auth: buildSmtpAuth(config.user, config.credentials),
181
+ connectionTimeout: TEST_TIMEOUT_MS,
182
+ greetingTimeout: TEST_TIMEOUT_MS,
183
+ socketTimeout: TEST_TIMEOUT_MS,
184
+ });
185
+
186
+ const attempt = transport
187
+ .verify()
188
+ .then(() => ({ success: true as const }))
189
+ .catch((error: unknown) => {
190
+ if (error instanceof MailConnectionError) {
191
+ return { success: false as const, error: error.message };
192
+ }
193
+ return {
194
+ success: false as const,
195
+ error: error instanceof Error ? error.message : "Connection failed",
196
+ };
197
+ })
198
+ .finally(() => transport.close());
199
+
200
+ const timeout = new Promise<TestResult>((resolve) =>
201
+ setTimeout(() => {
202
+ transport.close();
203
+ resolve({ success: false, error: "Connection timed out" });
204
+ }, TEST_TIMEOUT_MS),
205
+ );
206
+
207
+ return Promise.race([attempt, timeout]);
208
+ };
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Unit tests for MailCredentials union and MailConnectionError.
3
+ *
4
+ * Covers:
5
+ * 1. Auth object construction — imapflow 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 classification — createImapFlowConnectionFromAccount wires password
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import { inspect } from "node:util";
14
+ import { MailConnectionError, type MailCredentials } from "./types.js";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Helper: build the same imapflow auth object that imapflow-connection.ts uses
18
+ // (extracted here so the test doesn't import the whole class)
19
+ // ---------------------------------------------------------------------------
20
+
21
+ type ImapAuth =
22
+ | { user: string; pass: string }
23
+ | { user: string; accessToken: string };
24
+
25
+ const buildImapAuth = (
26
+ user: string,
27
+ credentials: MailCredentials,
28
+ ): ImapAuth => {
29
+ if (credentials.kind === "password") {
30
+ return { user, pass: credentials.password };
31
+ }
32
+ if (credentials.kind === "accessToken") {
33
+ return { user, accessToken: credentials.accessToken };
34
+ }
35
+ // Exhaustiveness check — TypeScript will error here if a new union member
36
+ // is added without updating this function.
37
+ const _exhaustive: never = credentials;
38
+ throw new Error(`Unknown credential kind: ${JSON.stringify(_exhaustive)}`);
39
+ };
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Helper: build the same nodemailer auth object that smtp-client.ts uses
43
+ // ---------------------------------------------------------------------------
44
+
45
+ type SmtpAuth =
46
+ | { user: string; pass: string }
47
+ | { type: "OAuth2"; user: string; accessToken: string };
48
+
49
+ const buildSmtpAuth = (
50
+ user: string,
51
+ credentials: MailCredentials,
52
+ ): SmtpAuth => {
53
+ if (credentials.kind === "password") {
54
+ return { user, pass: credentials.password };
55
+ }
56
+ if (credentials.kind === "accessToken") {
57
+ return {
58
+ type: "OAuth2" as const,
59
+ user,
60
+ accessToken: credentials.accessToken,
61
+ };
62
+ }
63
+ // Exhaustiveness check
64
+ const _exhaustive: never = credentials;
65
+ throw new Error(`Unknown credential kind: ${JSON.stringify(_exhaustive)}`);
66
+ };
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Tests
70
+ // ---------------------------------------------------------------------------
71
+
72
+ describe("MailCredentials — imapflow auth shape", () => {
73
+ it("password kind → { user, pass }", () => {
74
+ const creds: MailCredentials = { kind: "password", password: "s3cr3t" };
75
+ const auth = buildImapAuth("alice@example.com", creds);
76
+
77
+ assert.deepEqual(auth, { user: "alice@example.com", pass: "s3cr3t" });
78
+ assert.ok(
79
+ !("accessToken" in auth),
80
+ "no accessToken field for password auth",
81
+ );
82
+ });
83
+
84
+ it("accessToken kind → { user, accessToken }", () => {
85
+ const token = "ya29.super-secret-token";
86
+ const creds: MailCredentials = { kind: "accessToken", accessToken: token };
87
+ const auth = buildImapAuth("alice@example.com", creds);
88
+
89
+ assert.deepEqual(auth, {
90
+ user: "alice@example.com",
91
+ accessToken: token,
92
+ });
93
+ assert.ok(!("pass" in auth), "no pass field for accessToken auth");
94
+ });
95
+ });
96
+
97
+ describe("MailCredentials — nodemailer auth shape", () => {
98
+ it("password kind → { user, pass }", () => {
99
+ const creds: MailCredentials = { kind: "password", password: "s3cr3t" };
100
+ const auth = buildSmtpAuth("alice@example.com", creds);
101
+
102
+ assert.deepEqual(auth, { user: "alice@example.com", pass: "s3cr3t" });
103
+ assert.ok(!("type" in auth), "no OAuth2 type field for password auth");
104
+ });
105
+
106
+ it("accessToken kind → { type: 'OAuth2', user, accessToken }", () => {
107
+ const token = "ya29.super-secret-token";
108
+ const creds: MailCredentials = { kind: "accessToken", accessToken: token };
109
+ const auth = buildSmtpAuth("alice@example.com", creds);
110
+
111
+ assert.deepEqual(auth, {
112
+ type: "OAuth2",
113
+ user: "alice@example.com",
114
+ accessToken: token,
115
+ });
116
+ assert.ok(!("pass" in auth), "no pass field for OAuth2 auth");
117
+ });
118
+ });
119
+
120
+ describe("MailConnectionError", () => {
121
+ it("stores kind='auth'", () => {
122
+ const err = new MailConnectionError("auth", "IMAP authentication failed");
123
+ assert.equal(err.kind, "auth");
124
+ assert.equal(err.name, "MailConnectionError");
125
+ assert.equal(err.message, "IMAP authentication failed");
126
+ });
127
+
128
+ it("stores kind='network'", () => {
129
+ const err = new MailConnectionError(
130
+ "network",
131
+ "IMAP connection failed: ECONNREFUSED",
132
+ );
133
+ assert.equal(err.kind, "network");
134
+ });
135
+
136
+ it("token-leak: accessToken must NOT appear in inspected MailConnectionError", () => {
137
+ const secretToken = "ya29.A0ARrdaM_very_secret_access_token_12345";
138
+
139
+ // Simulate what connection code does: auth fails for an OAuth account.
140
+ // The error message must NOT mention the token, only "auth failed".
141
+ const err = new MailConnectionError(
142
+ "auth",
143
+ "IMAP authentication failed",
144
+ // The cause should also never include the token
145
+ );
146
+
147
+ // util.inspect walks the full error including the cause chain, so this
148
+ // proves no token leaks through name/message/stack OR cause.
149
+ const serialized = inspect(err, { depth: null });
150
+
151
+ assert.ok(
152
+ !serialized.includes(secretToken),
153
+ `MailConnectionError must not contain the access token — got: ${serialized.slice(0, 200)}`,
154
+ );
155
+ });
156
+
157
+ it("token-leak: accessToken in cause does NOT bubble into error.message", () => {
158
+ // Even if the underlying library error somehow includes the token text,
159
+ // our MailConnectionError message must not re-echo it.
160
+ const secretToken = "ya29.A0ARrdaM_very_secret_access_token_CAUSE";
161
+ const underlyingError = new Error(`AUTH failed token=${secretToken}`);
162
+
163
+ const err = new MailConnectionError(
164
+ "auth",
165
+ "IMAP authentication failed",
166
+ underlyingError,
167
+ );
168
+
169
+ // The message itself must be token-free
170
+ assert.ok(
171
+ !err.message.includes(secretToken),
172
+ "error.message must not contain the access token",
173
+ );
174
+ });
175
+
176
+ it("is instanceof Error", () => {
177
+ const err = new MailConnectionError("auth", "test");
178
+ assert.ok(err instanceof Error);
179
+ assert.ok(err instanceof MailConnectionError);
180
+ });
181
+ });
@@ -0,0 +1,181 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { FilterItem } from "@remit/data-ports";
4
+ import { FilterClauseField, FilterMatchOperator } from "@remit/domain-enums";
5
+ import {
6
+ buildMatchText,
7
+ clauseMatches,
8
+ cosineSimilarity,
9
+ type FilterMessage,
10
+ literalClausesMatch,
11
+ selectMoveWinner,
12
+ } from "./match.js";
13
+
14
+ type FilterClause = FilterItem["literalClauses"][number];
15
+
16
+ const message = (overrides: Partial<FilterMessage> = {}): FilterMessage => ({
17
+ from: "alice@acme.example",
18
+ fromName: "Alice Example",
19
+ subject: "Q3 invoice attached",
20
+ text: "Please find the invoice for the quarter attached.",
21
+ ...overrides,
22
+ });
23
+
24
+ const clause = (field: FilterClause["field"], value: string): FilterClause => ({
25
+ field,
26
+ value,
27
+ });
28
+
29
+ describe("clauseMatches", () => {
30
+ it("matches From against the sender address, case-insensitively", () => {
31
+ assert.equal(
32
+ clauseMatches(clause(FilterClauseField.From, "ACME.example"), message()),
33
+ true,
34
+ );
35
+ });
36
+
37
+ it("matches From against the sender display name", () => {
38
+ assert.equal(
39
+ clauseMatches(clause(FilterClauseField.From, "alice example"), message()),
40
+ true,
41
+ );
42
+ });
43
+
44
+ it("matches Subject as a substring", () => {
45
+ assert.equal(
46
+ clauseMatches(clause(FilterClauseField.Subject, "invoice"), message()),
47
+ true,
48
+ );
49
+ assert.equal(
50
+ clauseMatches(clause(FilterClauseField.Subject, "receipt"), message()),
51
+ false,
52
+ );
53
+ });
54
+
55
+ it("matches HasWords against subject or body", () => {
56
+ assert.equal(
57
+ clauseMatches(clause(FilterClauseField.HasWords, "quarter"), message()),
58
+ true,
59
+ );
60
+ assert.equal(
61
+ clauseMatches(clause(FilterClauseField.HasWords, "Q3"), message()),
62
+ true,
63
+ );
64
+ });
65
+
66
+ it("never matches an empty clause value", () => {
67
+ assert.equal(
68
+ clauseMatches(clause(FilterClauseField.HasWords, " "), message()),
69
+ false,
70
+ );
71
+ });
72
+ });
73
+
74
+ describe("literalClausesMatch", () => {
75
+ it("passes vacuously with no clauses (a purely-semantic filter)", () => {
76
+ assert.equal(
77
+ literalClausesMatch([], FilterMatchOperator.And, message()),
78
+ true,
79
+ );
80
+ });
81
+
82
+ it("And requires every clause to match", () => {
83
+ const clauses = [
84
+ clause(FilterClauseField.From, "acme.example"),
85
+ clause(FilterClauseField.Subject, "invoice"),
86
+ ];
87
+ assert.equal(
88
+ literalClausesMatch(clauses, FilterMatchOperator.And, message()),
89
+ true,
90
+ );
91
+ assert.equal(
92
+ literalClausesMatch(
93
+ [...clauses, clause(FilterClauseField.Subject, "nope")],
94
+ FilterMatchOperator.And,
95
+ message(),
96
+ ),
97
+ false,
98
+ );
99
+ });
100
+
101
+ it("Or requires only one clause to match", () => {
102
+ const clauses = [
103
+ clause(FilterClauseField.Subject, "nope"),
104
+ clause(FilterClauseField.Subject, "invoice"),
105
+ ];
106
+ assert.equal(
107
+ literalClausesMatch(clauses, FilterMatchOperator.Or, message()),
108
+ true,
109
+ );
110
+ assert.equal(
111
+ literalClausesMatch(
112
+ [clause(FilterClauseField.Subject, "nope")],
113
+ FilterMatchOperator.Or,
114
+ message(),
115
+ ),
116
+ false,
117
+ );
118
+ });
119
+ });
120
+
121
+ describe("cosineSimilarity", () => {
122
+ it("scores identical vectors as 1", () => {
123
+ assert.equal(cosineSimilarity([1, 2, 3], [1, 2, 3]), 1);
124
+ });
125
+
126
+ it("scores orthogonal vectors as 0", () => {
127
+ assert.equal(cosineSimilarity([1, 0], [0, 1]), 0);
128
+ });
129
+
130
+ it("scores a zero vector as 0 rather than dividing by zero", () => {
131
+ assert.equal(cosineSimilarity([0, 0], [1, 1]), 0);
132
+ });
133
+
134
+ it("throws on a dimension mismatch instead of scoring incomparable vectors", () => {
135
+ assert.throws(
136
+ () => cosineSimilarity([1, 2], [1, 2, 3]),
137
+ /dimension mismatch/,
138
+ );
139
+ });
140
+ });
141
+
142
+ describe("selectMoveWinner", () => {
143
+ const filter = (filterId: string, ruleChangedAt: number): FilterItem =>
144
+ ({ filterId, ruleChangedAt }) as FilterItem;
145
+
146
+ it("returns undefined with no candidates", () => {
147
+ assert.equal(selectMoveWinner([]), undefined);
148
+ });
149
+
150
+ it("picks the most-recently-changed filter", () => {
151
+ const winner = selectMoveWinner([
152
+ filter("a", 100),
153
+ filter("b", 300),
154
+ filter("c", 200),
155
+ ]);
156
+ assert.equal(winner?.filterId, "b");
157
+ });
158
+
159
+ it("tie-breaks on filterId when ruleChangedAt is identical", () => {
160
+ const winner = selectMoveWinner([
161
+ filter("a", 100),
162
+ filter("c", 100),
163
+ filter("b", 100),
164
+ ]);
165
+ assert.equal(winner?.filterId, "c");
166
+ });
167
+ });
168
+
169
+ describe("buildMatchText", () => {
170
+ it("joins subject and body and bounds the length", () => {
171
+ const text = buildMatchText(message({ subject: "hello", text: "world" }));
172
+ assert.equal(text, "hello\nworld");
173
+ });
174
+
175
+ it("caps at 512 characters", () => {
176
+ const text = buildMatchText(
177
+ message({ subject: "", text: "x".repeat(1000) }),
178
+ );
179
+ assert.equal(text.length, 512);
180
+ });
181
+ });