@open-mercato/channel-imap 0.6.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.
Files changed (114) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +56 -0
  3. package/build.mjs +7 -0
  4. package/dist/index.js +5 -0
  5. package/dist/index.js.map +7 -0
  6. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.js +62 -0
  7. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.js.map +7 -0
  8. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-002.spec.js +19 -0
  9. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-002.spec.js.map +7 -0
  10. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-003.spec.js +16 -0
  11. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-003.spec.js.map +7 -0
  12. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-021.spec.js +26 -0
  13. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-021.spec.js.map +7 -0
  14. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-022.spec.js +27 -0
  15. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-022.spec.js.map +7 -0
  16. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-023.spec.js +15 -0
  17. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-023.spec.js.map +7 -0
  18. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-024.spec.js +15 -0
  19. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-024.spec.js.map +7 -0
  20. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-025.spec.js +6 -0
  21. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-025.spec.js.map +7 -0
  22. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-026.spec.js +6 -0
  23. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-026.spec.js.map +7 -0
  24. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-027.spec.js +6 -0
  25. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-027.spec.js.map +7 -0
  26. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-028.spec.js +6 -0
  27. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-028.spec.js.map +7 -0
  28. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-029.spec.js +48 -0
  29. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-029.spec.js.map +7 -0
  30. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-030.spec.js +6 -0
  31. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-030.spec.js.map +7 -0
  32. package/dist/modules/channel_imap/acl.js +10 -0
  33. package/dist/modules/channel_imap/acl.js.map +7 -0
  34. package/dist/modules/channel_imap/di.js +23 -0
  35. package/dist/modules/channel_imap/di.js.map +7 -0
  36. package/dist/modules/channel_imap/index.js +9 -0
  37. package/dist/modules/channel_imap/index.js.map +7 -0
  38. package/dist/modules/channel_imap/integration.js +135 -0
  39. package/dist/modules/channel_imap/integration.js.map +7 -0
  40. package/dist/modules/channel_imap/lib/adapter.js +291 -0
  41. package/dist/modules/channel_imap/lib/adapter.js.map +7 -0
  42. package/dist/modules/channel_imap/lib/capabilities.js +8 -0
  43. package/dist/modules/channel_imap/lib/capabilities.js.map +7 -0
  44. package/dist/modules/channel_imap/lib/convert-outbound.js +54 -0
  45. package/dist/modules/channel_imap/lib/convert-outbound.js.map +7 -0
  46. package/dist/modules/channel_imap/lib/credentials.js +104 -0
  47. package/dist/modules/channel_imap/lib/credentials.js.map +7 -0
  48. package/dist/modules/channel_imap/lib/health.js +39 -0
  49. package/dist/modules/channel_imap/lib/health.js.map +7 -0
  50. package/dist/modules/channel_imap/lib/host-pinning.js +34 -0
  51. package/dist/modules/channel_imap/lib/host-pinning.js.map +7 -0
  52. package/dist/modules/channel_imap/lib/imap-client.js +210 -0
  53. package/dist/modules/channel_imap/lib/imap-client.js.map +7 -0
  54. package/dist/modules/channel_imap/lib/normalize-inbound.js +19 -0
  55. package/dist/modules/channel_imap/lib/normalize-inbound.js.map +7 -0
  56. package/dist/modules/channel_imap/lib/smtp-client.js +113 -0
  57. package/dist/modules/channel_imap/lib/smtp-client.js.map +7 -0
  58. package/dist/modules/channel_imap/lib/transport.js +17 -0
  59. package/dist/modules/channel_imap/lib/transport.js.map +7 -0
  60. package/dist/modules/channel_imap/lib/validate-credentials.js +69 -0
  61. package/dist/modules/channel_imap/lib/validate-credentials.js.map +7 -0
  62. package/dist/modules/channel_imap/setup.js +25 -0
  63. package/dist/modules/channel_imap/setup.js.map +7 -0
  64. package/dist/modules/channel_imap/widgets/injection/connect/widget.client.js +337 -0
  65. package/dist/modules/channel_imap/widgets/injection/connect/widget.client.js.map +7 -0
  66. package/dist/modules/channel_imap/widgets/injection/connect/widget.js +17 -0
  67. package/dist/modules/channel_imap/widgets/injection/connect/widget.js.map +7 -0
  68. package/dist/modules/channel_imap/widgets/injection-table.js +14 -0
  69. package/dist/modules/channel_imap/widgets/injection-table.js.map +7 -0
  70. package/jest.config.cjs +34 -0
  71. package/package.json +99 -0
  72. package/src/index.ts +1 -0
  73. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.ts +80 -0
  74. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-002.spec.ts +28 -0
  75. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-003.spec.ts +23 -0
  76. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-021.spec.ts +40 -0
  77. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-022.spec.ts +38 -0
  78. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-023.spec.ts +31 -0
  79. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-024.spec.ts +27 -0
  80. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-025.spec.ts +23 -0
  81. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-026.spec.ts +18 -0
  82. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-027.spec.ts +18 -0
  83. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-028.spec.ts +19 -0
  84. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-029.spec.ts +72 -0
  85. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-030.spec.ts +19 -0
  86. package/src/modules/channel_imap/acl.ts +6 -0
  87. package/src/modules/channel_imap/di.ts +26 -0
  88. package/src/modules/channel_imap/index.ts +6 -0
  89. package/src/modules/channel_imap/integration.ts +131 -0
  90. package/src/modules/channel_imap/lib/__tests__/adapter.test.ts +499 -0
  91. package/src/modules/channel_imap/lib/__tests__/convert-outbound.test.ts +73 -0
  92. package/src/modules/channel_imap/lib/__tests__/credentials.test.ts +154 -0
  93. package/src/modules/channel_imap/lib/__tests__/host-pinning.test.ts +68 -0
  94. package/src/modules/channel_imap/lib/__tests__/imap-client.test.ts +180 -0
  95. package/src/modules/channel_imap/lib/__tests__/normalize-inbound.test.ts +126 -0
  96. package/src/modules/channel_imap/lib/__tests__/transport.test.ts +68 -0
  97. package/src/modules/channel_imap/lib/__tests__/validate-credentials.test.ts +156 -0
  98. package/src/modules/channel_imap/lib/adapter.ts +451 -0
  99. package/src/modules/channel_imap/lib/capabilities.ts +16 -0
  100. package/src/modules/channel_imap/lib/convert-outbound.ts +79 -0
  101. package/src/modules/channel_imap/lib/credentials.ts +172 -0
  102. package/src/modules/channel_imap/lib/health.ts +70 -0
  103. package/src/modules/channel_imap/lib/host-pinning.ts +59 -0
  104. package/src/modules/channel_imap/lib/imap-client.ts +382 -0
  105. package/src/modules/channel_imap/lib/normalize-inbound.ts +47 -0
  106. package/src/modules/channel_imap/lib/smtp-client.ts +214 -0
  107. package/src/modules/channel_imap/lib/transport.ts +37 -0
  108. package/src/modules/channel_imap/lib/validate-credentials.ts +98 -0
  109. package/src/modules/channel_imap/setup.ts +34 -0
  110. package/src/modules/channel_imap/widgets/injection/connect/widget.client.tsx +359 -0
  111. package/src/modules/channel_imap/widgets/injection/connect/widget.ts +16 -0
  112. package/src/modules/channel_imap/widgets/injection-table.ts +12 -0
  113. package/tsconfig.json +9 -0
  114. package/watch.mjs +7 -0
@@ -0,0 +1,382 @@
1
+ import type { ImapCredentials } from './credentials'
2
+ import { resolveSafeHostAddress } from './host-pinning'
3
+ import { assertTransportAllowed } from './transport'
4
+
5
+ /**
6
+ * Thin wrapper around `imapflow` so the adapter and tests can stay agnostic of
7
+ * the SDK shape. We only expose the operations the adapter actually performs:
8
+ * - `connectAndValidate` — open + LOGIN + LIST capabilities (used by `validateCredentials`)
9
+ * - `selectInbox` — open the INBOX mailbox and read UIDVALIDITY / UIDNEXT
10
+ * - `fetchUidRange` — fetch RFC822 bodies for a UID range (used by polling worker)
11
+ * - `appendSent` — append a sent message to the Sent folder if available
12
+ *
13
+ * The wrapper avoids leaking `imapflow` types to callers so we can swap to
14
+ * `node-imap` or a mock without touching adapter code.
15
+ */
16
+
17
+ export type ImapTransport = 'tls' | 'starttls' | 'none'
18
+
19
+ export interface ImapConnectionOptions {
20
+ host: string
21
+ port: number
22
+ user: string
23
+ pass: string
24
+ transport: ImapTransport
25
+ /** Connection + greeting timeout (ms). Default 60000 (Spec B). */
26
+ timeoutMs?: number
27
+ /**
28
+ * TCP+TLS connect + greeting timeout (ms). Default 15000. The health probe
29
+ * passes a tighter value so it fails fast within the hub's 10s budget.
30
+ */
31
+ connectTimeoutMs?: number
32
+ }
33
+
34
+ export interface ImapFolderState {
35
+ uidValidity?: number
36
+ uidNext?: number
37
+ exists?: number
38
+ }
39
+
40
+ export interface ImapFetchedMessage {
41
+ uid: number
42
+ rawBody: Buffer
43
+ /** Server-reported INTERNALDATE — fallback when MIME date headers are missing. */
44
+ internalDate?: Date
45
+ /** Server flags (`\Seen`, `\Answered`, …). */
46
+ flags?: string[]
47
+ }
48
+
49
+ export interface ImapClient {
50
+ connectAndValidate(options: ImapConnectionOptions): Promise<{ capabilities: string[] }>
51
+ selectInbox(
52
+ options: ImapConnectionOptions,
53
+ ): Promise<ImapFolderState>
54
+ fetchUidRange(
55
+ options: ImapConnectionOptions,
56
+ range: string,
57
+ opts?: { limit?: number },
58
+ ): Promise<ImapFetchedMessage[]>
59
+ /**
60
+ * Run an IMAP `SEARCH` (with `UID` flag) and return matching UIDs.
61
+ * Supports `OR FROM` chaining (server-side sender filter) and `SINCE` date
62
+ * narrowing. Used by the inbound poll path to avoid pulling the entire
63
+ * mailbox when the hub only cares about messages from known CRM contacts.
64
+ *
65
+ * `fromAddresses` is OR'd: `OR FROM "a@x.com" OR FROM "b@y.com" FROM "c@z.com"`.
66
+ * `sinceDate` is formatted as IMAP date (`DD-Mon-YYYY`) for the SINCE clause.
67
+ * Returns UIDs in mailbox order (typically ascending). Empty array = no match.
68
+ */
69
+ searchUidsByFromAndSince(
70
+ options: ImapConnectionOptions,
71
+ criteria: { fromAddresses?: string[]; sinceDate?: Date },
72
+ ): Promise<number[]>
73
+ appendSent(
74
+ options: ImapConnectionOptions,
75
+ rawMessage: Buffer,
76
+ ): Promise<void>
77
+ }
78
+
79
+ /**
80
+ * Default IMAP client backed by `imapflow`. Imported lazily so test environments
81
+ * that don't install `imapflow` (the unit tests use a hand-rolled mock) keep working.
82
+ */
83
+ class ImapflowClient implements ImapClient {
84
+ private async openConnection(options: ImapConnectionOptions): Promise<ImapflowConnection> {
85
+ const { ImapFlow } = await loadImapFlow()
86
+ // Resolve + pin the host to a validated public IP at connect time, so a
87
+ // hostname that (re)resolves to an internal address can't be abused for
88
+ // SSRF. We dial the IP but keep the hostname as the TLS servername so SNI +
89
+ // certificate hostname verification still target the real host.
90
+ const pinned = await resolveSafeHostAddress(options.host)
91
+ const client = new ImapFlow({
92
+ host: pinned.host,
93
+ port: options.port,
94
+ secure: options.transport === 'tls',
95
+ auth: { user: options.user, pass: options.pass },
96
+ // Enforce certificate verification on every encrypted transport (implicit
97
+ // TLS and STARTTLS). Only cleartext ('none', gated behind an env opt-in)
98
+ // omits the TLS options. Mirrors the SMTP client so an upstream default
99
+ // change can't silently disable cert checks.
100
+ tls:
101
+ options.transport === 'none'
102
+ ? undefined
103
+ : { rejectUnauthorized: true, ...(pinned.servername ? { servername: pinned.servername } : {}) },
104
+ logger: false,
105
+ // Gmail's IMAP can take 15-30s to respond to NAMESPACE under load even
106
+ // after a successful AUTHENTICATE — observed during demo with valid
107
+ // credentials and clean TLS. A 10s socket timeout aborts the command
108
+ // mid-stream and surfaces as "NoConnection"/"Unexpected close" to the
109
+ // worker, which then marks the channel as 'error'. 60s is enough for
110
+ // any reasonable IMAP server while still bailing on truly dead hosts.
111
+ socketTimeout: options.timeoutMs ?? 60_000,
112
+ // Initial TCP+TLS handshake is usually fast; cap at 15s so a non-responsive
113
+ // host bails before the UI flow stalls. Greeting can be slow on some
114
+ // providers (Gmail occasionally takes 5-10s), so allow 15s there too.
115
+ connectionTimeout: options.connectTimeoutMs ?? 15_000,
116
+ greetingTimeout: options.connectTimeoutMs ?? 15_000,
117
+ } as Record<string, unknown>)
118
+ // Attach a defensive 'error' listener so tcp-level errors emitted on the
119
+ // EventEmitter (e.g. socket reset during an idle lock) don't crash the
120
+ // Node process via `unhandledError`. The error still bubbles up through
121
+ // the awaited operation; the listener exists purely to satisfy Node's
122
+ // EventEmitter contract.
123
+ const eventClient = client as unknown as { on?: (event: string, listener: (err: unknown) => void) => void }
124
+ if (typeof eventClient.on === 'function') {
125
+ eventClient.on('error', () => {
126
+ // Swallow — surfaced to the caller via the awaited promise.
127
+ })
128
+ }
129
+ await client.connect()
130
+ if (options.transport === 'starttls') {
131
+ // Verify STARTTLS actually upgraded the connection. ImapFlow exposes the
132
+ // negotiated state via `secureConnection` (true after STARTTLS) — refuse
133
+ // to proceed if the server didn't advertise it.
134
+ const secured = (client as unknown as { secureConnection?: boolean }).secureConnection === true
135
+ if (!secured) {
136
+ await client.logout().catch(() => undefined)
137
+ throw new Error(
138
+ 'IMAP server did not advertise STARTTLS — cannot authenticate over cleartext. Switch transport to tls (port 993) or contact the mailbox provider.',
139
+ )
140
+ }
141
+ }
142
+ return client
143
+ }
144
+
145
+ async connectAndValidate(options: ImapConnectionOptions): Promise<{ capabilities: string[] }> {
146
+ const client = await this.openConnection(options)
147
+ try {
148
+ // imapflow exposes capabilities as a `Map<string, boolean | string>` —
149
+ // iterating yields `[key, value]` tuples which break `.map(String)`.
150
+ // Read the keys explicitly so consumers get the capability names.
151
+ const capabilityKeys = extractCapabilityKeys(client)
152
+ return { capabilities: capabilityKeys }
153
+ } finally {
154
+ await client.logout().catch(() => undefined)
155
+ }
156
+ }
157
+
158
+ async selectInbox(options: ImapConnectionOptions): Promise<ImapFolderState> {
159
+ const client = await this.openConnection(options)
160
+ try {
161
+ const lock = await client.getMailboxLock('INBOX')
162
+ try {
163
+ const mailbox = client.mailbox as { uidValidity?: number | bigint; uidNext?: number | bigint; exists?: number } | null
164
+ if (!mailbox) return {}
165
+ return {
166
+ uidValidity: typeof mailbox.uidValidity === 'bigint' ? Number(mailbox.uidValidity) : mailbox.uidValidity,
167
+ uidNext: typeof mailbox.uidNext === 'bigint' ? Number(mailbox.uidNext) : mailbox.uidNext,
168
+ exists: mailbox.exists,
169
+ }
170
+ } finally {
171
+ lock.release()
172
+ }
173
+ } finally {
174
+ await client.logout().catch(() => undefined)
175
+ }
176
+ }
177
+
178
+ async fetchUidRange(
179
+ options: ImapConnectionOptions,
180
+ range: string,
181
+ opts: { limit?: number } = {},
182
+ ): Promise<ImapFetchedMessage[]> {
183
+ const client = await this.openConnection(options)
184
+ const out: ImapFetchedMessage[] = []
185
+ try {
186
+ const lock = await client.getMailboxLock('INBOX')
187
+ try {
188
+ // `{ uid: true }` as the THIRD arg (FetchOptions) makes imapflow treat
189
+ // `range` as a UID range. Without it the range is read as message-sequence
190
+ // numbers, and a sequence range like "200:*" collapses to the single newest
191
+ // message ("*") — so each poll would fetch only the latest mail and silently
192
+ // skip every other message that arrived in the same gap. The `uid: true` in
193
+ // the SECOND arg (FetchQueryObject) is unrelated: it only asks to include the
194
+ // UID field in each response row.
195
+ const iterator = client.fetch(
196
+ range,
197
+ { uid: true, source: true, internalDate: true, flags: true },
198
+ { uid: true },
199
+ )
200
+ for await (const message of iterator) {
201
+ if (!message.source) continue
202
+ out.push({
203
+ uid: Number(message.uid),
204
+ rawBody: Buffer.isBuffer(message.source) ? message.source : Buffer.from(message.source),
205
+ internalDate: message.internalDate ? new Date(message.internalDate) : undefined,
206
+ flags: message.flags ? Array.from(message.flags as Iterable<string>) : undefined,
207
+ })
208
+ if (opts.limit && out.length >= opts.limit) break
209
+ }
210
+ } finally {
211
+ lock.release()
212
+ }
213
+ } finally {
214
+ await client.logout().catch(() => undefined)
215
+ }
216
+ return out
217
+ }
218
+
219
+ async searchUidsByFromAndSince(
220
+ options: ImapConnectionOptions,
221
+ criteria: { fromAddresses?: string[]; sinceDate?: Date },
222
+ ): Promise<number[]> {
223
+ const client = await this.openConnection(options)
224
+ try {
225
+ const lock = await client.getMailboxLock('INBOX')
226
+ try {
227
+ // imapflow's search() takes a SearchQuery object. We construct one that
228
+ // mirrors `SEARCH (OR FROM ... FROM ...) SINCE DD-Mon-YYYY` using its
229
+ // documented shapes:
230
+ // - `from` accepts a single string; for multiple we use `or: [{from}, {from}]`
231
+ // (recursive — imapflow flattens to `OR (FROM a) (FROM b)` IMAP syntax).
232
+ // - `since` accepts a Date and imapflow formats as `SINCE DD-Mon-YYYY`.
233
+ const query: Record<string, unknown> = {}
234
+ const addresses = (criteria.fromAddresses ?? [])
235
+ .map((s) => (typeof s === 'string' ? s.trim() : ''))
236
+ .filter((s) => s.length > 0)
237
+ if (addresses.length === 1) {
238
+ query.from = addresses[0]
239
+ } else if (addresses.length > 1) {
240
+ // Build nested OR: imapflow's `or` field expects an array of SearchQuery
241
+ // objects. With 2 entries: `OR (FROM a) (FROM b)`. With N > 2 entries
242
+ // we chain right-associatively: `OR (FROM a) (OR (FROM b) (FROM c) ...)`.
243
+ let acc: Record<string, unknown> = { from: addresses[addresses.length - 1] }
244
+ for (let i = addresses.length - 2; i >= 0; i--) {
245
+ acc = { or: [{ from: addresses[i] }, acc] }
246
+ }
247
+ Object.assign(query, acc)
248
+ }
249
+ if (criteria.sinceDate instanceof Date && !Number.isNaN(criteria.sinceDate.getTime())) {
250
+ query.since = criteria.sinceDate
251
+ }
252
+ if (Object.keys(query).length === 0) return []
253
+
254
+ const searchFn = (client as unknown as {
255
+ search?: (q: Record<string, unknown>, opts?: { uid?: boolean }) => Promise<Array<number | bigint> | false>
256
+ }).search
257
+ if (typeof searchFn !== 'function') return []
258
+ const raw = await searchFn.call(client, query, { uid: true })
259
+ if (raw === false || !Array.isArray(raw)) return []
260
+ return raw.map((u) => (typeof u === 'bigint' ? Number(u) : u)).filter((u) => Number.isFinite(u))
261
+ } finally {
262
+ lock.release()
263
+ }
264
+ } finally {
265
+ await client.logout().catch(() => undefined)
266
+ }
267
+ }
268
+
269
+ async appendSent(options: ImapConnectionOptions, rawMessage: Buffer): Promise<void> {
270
+ const client = await this.openConnection(options)
271
+ try {
272
+ const sentMailbox = await resolveSentMailbox(client)
273
+ await client.append(sentMailbox, rawMessage, ['\\Seen'])
274
+ } finally {
275
+ await client.logout().catch(() => undefined)
276
+ }
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Pick the server's Sent folder from a `LIST` response. Providers expose it
282
+ * under different paths ('[Gmail]/Sent Mail', localized names, …) so we match
283
+ * the RFC 6154 SPECIAL-USE `\Sent` attribute rather than assuming 'Sent'.
284
+ */
285
+ export function pickSentMailbox(
286
+ mailboxes: Array<{ path?: string; specialUse?: string }> | null | undefined,
287
+ ): string {
288
+ if (Array.isArray(mailboxes)) {
289
+ const sent = mailboxes.find(
290
+ (mailbox) =>
291
+ mailbox?.specialUse === '\\Sent' && typeof mailbox.path === 'string' && mailbox.path.length > 0,
292
+ )
293
+ if (sent?.path) return sent.path
294
+ }
295
+ return 'Sent'
296
+ }
297
+
298
+ async function resolveSentMailbox(client: ImapflowConnection): Promise<string> {
299
+ // Discover the real Sent folder via SPECIAL-USE; fall back to the conventional
300
+ // 'Sent' when listing is unsupported or no \Sent mailbox is advertised.
301
+ try {
302
+ const mailboxes = typeof client.list === 'function' ? await client.list() : undefined
303
+ return pickSentMailbox(mailboxes)
304
+ } catch {
305
+ // LIST failed (server quirk / transient) — fall back to the conventional folder.
306
+ return 'Sent'
307
+ }
308
+ }
309
+
310
+ function extractCapabilityKeys(client: ImapflowConnection): string[] {
311
+ // imapflow's `client.capabilities` is a `Map<string, boolean | string>`
312
+ // (see imapflow/lib/imap-flow.js — `this.capabilities = new Map()`). The
313
+ // legacy `serverInfo?.capability` (set by the ID response) may be an
314
+ // iterable of strings; prefer it when present, otherwise read the Map keys.
315
+ const fromServerInfo = client.serverInfo?.capability
316
+ if (fromServerInfo) {
317
+ return Array.from(fromServerInfo).map((value) => String(value).toUpperCase())
318
+ }
319
+ const caps = client.capabilities
320
+ if (!caps) return []
321
+ if (caps instanceof Map) {
322
+ return Array.from(caps.keys()).map((value) => String(value).toUpperCase())
323
+ }
324
+ // Fallback for non-Map iterables (test mocks).
325
+ return Array.from(caps as Iterable<string>).map((value) => String(value).toUpperCase())
326
+ }
327
+
328
+ interface ImapflowConnection {
329
+ serverInfo?: { capability?: Iterable<string> }
330
+ capabilities?: Iterable<string> | Map<string, unknown>
331
+ mailbox: unknown
332
+ connect(): Promise<void>
333
+ logout(): Promise<void>
334
+ getMailboxLock(name: string): Promise<{ release(): void }>
335
+ fetch(range: string, query: Record<string, unknown>, options?: Record<string, unknown>): AsyncIterable<{ uid: number; source?: Buffer | string; internalDate?: Date | string; flags?: Iterable<string> }>
336
+ append(mailbox: string, rawMessage: Buffer, flags?: string[]): Promise<void>
337
+ list?(): Promise<Array<{ path?: string; specialUse?: string }>>
338
+ }
339
+
340
+ async function loadImapFlow(): Promise<{ ImapFlow: new (options: Record<string, unknown>) => ImapflowConnection }> {
341
+ // Dynamic import so unit tests that mock the client don't require `imapflow` installed.
342
+ const mod = (await import('imapflow')) as unknown as { ImapFlow: new (options: Record<string, unknown>) => ImapflowConnection }
343
+ return { ImapFlow: mod.ImapFlow }
344
+ }
345
+
346
+ let cachedClient: ImapClient | null = null
347
+
348
+ export function getImapClient(): ImapClient {
349
+ if (!cachedClient) cachedClient = new ImapflowClient()
350
+ return cachedClient
351
+ }
352
+
353
+ /**
354
+ * Test-only hook to swap the default IMAP client with a mock implementation.
355
+ * Production code never calls this.
356
+ */
357
+ export function setImapClient(client: ImapClient | null): void {
358
+ cachedClient = client
359
+ }
360
+
361
+ export function credentialsToConnection(credentials: ImapCredentials): ImapConnectionOptions {
362
+ assertTransportAllowed(credentials)
363
+ const timeoutMs = resolveSocketTimeoutMs()
364
+ return {
365
+ host: credentials.imapHost,
366
+ port: Number(credentials.imapPort),
367
+ user: credentials.imapUser,
368
+ pass: credentials.imapPassword,
369
+ transport: credentials.imapTls,
370
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Operator override for the IMAP socket timeout. Defaults (when unset/invalid)
376
+ * to `undefined` so the client falls back to its 60s default; the previous 10s
377
+ * flaked under real-world IMAP latency. Spec § Configuration documents this knob.
378
+ */
379
+ function resolveSocketTimeoutMs(): number | undefined {
380
+ const raw = Number.parseInt(process.env.OM_CHANNEL_IMAP_SOCKET_TIMEOUT_MS ?? '', 10)
381
+ return Number.isFinite(raw) && raw > 0 ? raw : undefined
382
+ }
@@ -0,0 +1,47 @@
1
+ import type { NormalizedInboundMessage } from '@open-mercato/core/modules/communication_channels/lib/adapter'
2
+ import {
3
+ normalizeMimeInbound,
4
+ type ParsedMail,
5
+ } from '@open-mercato/core/modules/communication_channels/lib/email-mime'
6
+
7
+ /**
8
+ * Convert a raw RFC2822 MIME buffer (delivered by IMAP fetch) to the hub's
9
+ * canonical `NormalizedInboundMessage`. Parses with `mailparser`, then delegates
10
+ * threading / attachments / headers to the shared `normalizeMimeInbound` helper.
11
+ *
12
+ * Threading:
13
+ * - `externalMessageId` := MIME `Message-ID` header (RFC2822). Required by
14
+ * IMAP/SMTP; if missing we fall back to `imap:<uid>@<account>` so downstream
15
+ * idempotency still has a deterministic key.
16
+ * - `replyToExternalId` := `In-Reply-To` header (single value).
17
+ * - `externalConversationId` := the root of the References chain when present,
18
+ * otherwise the message id itself (single-message thread).
19
+ */
20
+
21
+ export interface NormalizeInboundOptions {
22
+ rawMessage: Buffer
23
+ /** UID from the IMAP fetch — embedded into `channelMetadata.uid` for diagnostics. */
24
+ uid?: number
25
+ /** External identifier of the receiving channel (typically the account's email). */
26
+ accountIdentifier: string
27
+ /** Fallback timestamp if the parsed message has no Date header. */
28
+ fallbackDate?: Date
29
+ }
30
+
31
+ export async function normalizeInboundImapMessage(
32
+ options: NormalizeInboundOptions,
33
+ ): Promise<NormalizedInboundMessage> {
34
+ const mailparser = (await import('mailparser')) as unknown as {
35
+ simpleParser: (buf: Buffer | string) => Promise<ParsedMail>
36
+ }
37
+ const parsed = await mailparser.simpleParser(options.rawMessage)
38
+
39
+ return normalizeMimeInbound({
40
+ parsed,
41
+ accountIdentifier: options.accountIdentifier,
42
+ fallbackMessageId: `imap:${options.uid ?? 'unknown'}@${options.accountIdentifier}`,
43
+ resolveConversationId: ({ messageId, references }) => references[0] ?? messageId,
44
+ fallbackDate: options.fallbackDate,
45
+ channelMetadata: () => ({ uid: options.uid }),
46
+ })
47
+ }
@@ -0,0 +1,214 @@
1
+ import type { ImapCredentials } from './credentials'
2
+ import { resolveSafeHostAddress } from './host-pinning'
3
+ import { assertTransportAllowed } from './transport'
4
+
5
+ /**
6
+ * Outbound SMTP client wrapper. Same trade-offs as `imap-client.ts`: we wrap
7
+ * `nodemailer` behind a tiny interface so tests can swap in a mock and the
8
+ * adapter doesn't import SDK types directly.
9
+ */
10
+
11
+ export interface SmtpConnectionOptions {
12
+ host: string
13
+ port: number
14
+ user: string
15
+ pass: string
16
+ transport: 'tls' | 'starttls' | 'none'
17
+ timeoutMs?: number
18
+ }
19
+
20
+ export interface SmtpMessage {
21
+ from: string
22
+ to: string[]
23
+ cc?: string[]
24
+ bcc?: string[]
25
+ subject?: string
26
+ text?: string
27
+ html?: string
28
+ /** RFC2822 Message-ID; if omitted nodemailer generates one. */
29
+ messageId?: string
30
+ /** RFC2822 In-Reply-To (single value). */
31
+ inReplyTo?: string
32
+ /** RFC2822 References (whitespace-delimited list). */
33
+ references?: string[]
34
+ attachments?: Array<{
35
+ filename: string
36
+ content: Buffer
37
+ contentType?: string
38
+ cid?: string
39
+ inline?: boolean
40
+ }>
41
+ headers?: Record<string, string>
42
+ }
43
+
44
+ export interface SmtpSendResult {
45
+ /** Effective Message-ID. */
46
+ messageId: string
47
+ /** Raw RFC2822 message buffer (used for Sent-folder append). */
48
+ raw: Buffer
49
+ /** Provider response string. */
50
+ response?: string
51
+ }
52
+
53
+ export interface SmtpClient {
54
+ verify(options: SmtpConnectionOptions): Promise<void>
55
+ send(options: SmtpConnectionOptions, message: SmtpMessage): Promise<SmtpSendResult>
56
+ }
57
+
58
+ class NodemailerClient implements SmtpClient {
59
+ async verify(options: SmtpConnectionOptions): Promise<void> {
60
+ const { transporter } = await this.createTransporter(options)
61
+ try {
62
+ await transporter.verify()
63
+ } finally {
64
+ // Mirror send(): close on every path so a failed verify (wrong password,
65
+ // unreachable host — the common case) does not leak the socket pool.
66
+ transporter.close()
67
+ }
68
+ }
69
+
70
+ async send(options: SmtpConnectionOptions, message: SmtpMessage): Promise<SmtpSendResult> {
71
+ const { transporter, MailComposer } = await this.createTransporter(options)
72
+ try {
73
+ const mailOptions: Record<string, unknown> = {
74
+ from: message.from,
75
+ to: message.to,
76
+ cc: message.cc,
77
+ bcc: message.bcc,
78
+ subject: message.subject,
79
+ text: message.text,
80
+ html: message.html,
81
+ messageId: message.messageId,
82
+ inReplyTo: message.inReplyTo,
83
+ references: message.references,
84
+ attachments: message.attachments?.map((a) => ({
85
+ filename: a.filename,
86
+ content: a.content,
87
+ contentType: a.contentType,
88
+ cid: a.cid,
89
+ contentDisposition: a.inline ? 'inline' : 'attachment',
90
+ })),
91
+ headers: message.headers,
92
+ }
93
+
94
+ // Build the RFC2822 bytes ourselves via MailComposer so we can capture
95
+ // them for the Sent-folder append (review H1, 2026-05-26).
96
+ // nodemailer's `transporter.sendMail` info object does NOT contain `raw`
97
+ // unless you configure a streamTransport, so naively reading
98
+ // `info.raw` produces a 0-byte buffer and the Sent-folder append uploads
99
+ // a corrupt message.
100
+ let raw: Buffer = Buffer.alloc(0)
101
+ let composedMessageId = message.messageId
102
+ if (typeof MailComposer === 'function') {
103
+ try {
104
+ const composed = new MailComposer(mailOptions) as unknown as {
105
+ compile: () => {
106
+ build: (callback: (err: Error | null, output: Buffer) => void) => void
107
+ messageId?: () => string | undefined
108
+ }
109
+ }
110
+ const compiled = composed.compile()
111
+ raw = await new Promise<Buffer>((resolve, reject) => {
112
+ compiled.build((err, output) => {
113
+ if (err) reject(err)
114
+ else resolve(output)
115
+ })
116
+ })
117
+ const messageIdFn = compiled.messageId
118
+ if (typeof messageIdFn === 'function') {
119
+ composedMessageId = messageIdFn.call(compiled) ?? composedMessageId
120
+ }
121
+ } catch (composeError) {
122
+ // MailComposer build failed: the send below still delivers the mail, but we
123
+ // cannot capture the RFC2822 bytes, so the caller skips the Sent-folder append.
124
+ // Log so operators can diagnose missing Sent archival.
125
+ raw = Buffer.alloc(0)
126
+ console.warn(
127
+ '[internal] channel_imap: failed to build RFC2822 bytes for Sent-folder append:',
128
+ composeError instanceof Error ? composeError.message : composeError,
129
+ )
130
+ }
131
+ }
132
+
133
+ const info = (await transporter.sendMail(mailOptions)) as {
134
+ messageId?: string
135
+ envelope?: { messageId?: string }
136
+ response?: string
137
+ }
138
+ const id = info.messageId ?? composedMessageId ?? info.envelope?.messageId
139
+ if (!id) throw new Error('[internal] SMTP server did not return a Message-ID')
140
+ return { messageId: id, raw, response: info.response }
141
+ } finally {
142
+ transporter.close()
143
+ }
144
+ }
145
+
146
+ private async createTransporter(
147
+ options: SmtpConnectionOptions,
148
+ ): Promise<{
149
+ transporter: NodemailerTransporter
150
+ MailComposer: (new (mail: Record<string, unknown>) => unknown) | undefined
151
+ }> {
152
+ const mod = (await import('nodemailer')) as unknown as {
153
+ default?: {
154
+ createTransport: (opts: Record<string, unknown>) => NodemailerTransporter
155
+ MailComposer?: new (mail: Record<string, unknown>) => unknown
156
+ }
157
+ createTransport?: (opts: Record<string, unknown>) => NodemailerTransporter
158
+ MailComposer?: new (mail: Record<string, unknown>) => unknown
159
+ }
160
+ const createTransport = mod.createTransport ?? mod.default?.createTransport
161
+ if (typeof createTransport !== 'function') {
162
+ throw new Error('nodemailer.createTransport is unavailable')
163
+ }
164
+ const MailComposer = mod.MailComposer ?? mod.default?.MailComposer
165
+ // Resolve + pin the SMTP host to a validated public IP at connect time
166
+ // (DNS-rebinding-safe), keeping the hostname as the TLS servername for SNI +
167
+ // certificate hostname verification.
168
+ const pinned = await resolveSafeHostAddress(options.host)
169
+ const transporter = createTransport({
170
+ host: pinned.host,
171
+ port: options.port,
172
+ secure: options.transport === 'tls',
173
+ requireTLS: options.transport === 'starttls',
174
+ auth: { user: options.user, pass: options.pass },
175
+ connectionTimeout: options.timeoutMs ?? 10_000,
176
+ // Reject downgrade attacks: only allow cleartext when the operator
177
+ // explicitly opts into `transport: 'none'`. Even then, refuse to skip
178
+ // certificate verification on STARTTLS / TLS.
179
+ tls:
180
+ options.transport === 'none'
181
+ ? undefined
182
+ : { rejectUnauthorized: true, ...(pinned.servername ? { servername: pinned.servername } : {}) },
183
+ })
184
+ return { transporter, MailComposer }
185
+ }
186
+ }
187
+
188
+ interface NodemailerTransporter {
189
+ verify(): Promise<true>
190
+ sendMail(options: Record<string, unknown>): Promise<unknown>
191
+ close(): void
192
+ }
193
+
194
+ let cachedClient: SmtpClient | null = null
195
+
196
+ export function getSmtpClient(): SmtpClient {
197
+ if (!cachedClient) cachedClient = new NodemailerClient()
198
+ return cachedClient
199
+ }
200
+
201
+ export function setSmtpClient(client: SmtpClient | null): void {
202
+ cachedClient = client
203
+ }
204
+
205
+ export function credentialsToSmtpConnection(credentials: ImapCredentials): SmtpConnectionOptions {
206
+ assertTransportAllowed(credentials)
207
+ return {
208
+ host: credentials.smtpHost,
209
+ port: Number(credentials.smtpPort),
210
+ user: credentials.smtpUser,
211
+ pass: credentials.smtpPassword,
212
+ transport: credentials.smtpTls,
213
+ }
214
+ }
@@ -0,0 +1,37 @@
1
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
2
+ import type { ImapCredentials } from './credentials'
3
+
4
+ /**
5
+ * Single source of truth for the cleartext-transport policy.
6
+ *
7
+ * `imapTls`/`smtpTls === 'none'` disables TLS entirely and sends the password in
8
+ * the clear over an attacker-controlled host string. The credential schema still
9
+ * *permits* `'none'`, so this guard — not the schema — is what actually rejects
10
+ * it. Centralizing it here lets every code path (validate, health, send, poll,
11
+ * import) enforce one rule: a stored blob with `'none'` is refused on every
12
+ * connection build unless an operator opts in via
13
+ * `OM_CHANNEL_IMAP_ALLOW_INSECURE_TRANSPORT=true`. `'starttls'`/`'tls'` are
14
+ * always allowed.
15
+ */
16
+
17
+ export const INSECURE_TRANSPORT_MESSAGE =
18
+ 'Cleartext transport (None) is not allowed. Use STARTTLS or implicit TLS. ' +
19
+ 'An operator must set OM_CHANNEL_IMAP_ALLOW_INSECURE_TRANSPORT=true to permit it.'
20
+
21
+ export function isInsecureTransportAllowed(): boolean {
22
+ return parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INSECURE_TRANSPORT, false)
23
+ }
24
+
25
+ /**
26
+ * Throws when either transport is cleartext (`'none'`) and the operator opt-in
27
+ * flag is unset. Called inside the credentials → connection translators so it
28
+ * runs on every IMAP/SMTP connection build — including reads of credential blobs
29
+ * persisted while the flag was set, or written via a path that bypassed
30
+ * `validateImapCredentials`.
31
+ */
32
+ export function assertTransportAllowed(credentials: Pick<ImapCredentials, 'imapTls' | 'smtpTls'>): void {
33
+ if (isInsecureTransportAllowed()) return
34
+ if (credentials.imapTls === 'none' || credentials.smtpTls === 'none') {
35
+ throw new Error(`[internal] ${INSECURE_TRANSPORT_MESSAGE}`)
36
+ }
37
+ }