@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.
- package/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +56 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.js +62 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-002.spec.js +19 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-002.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-003.spec.js +16 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-003.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-021.spec.js +26 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-021.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-022.spec.js +27 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-022.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-023.spec.js +15 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-023.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-024.spec.js +15 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-024.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-025.spec.js +6 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-025.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-026.spec.js +6 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-026.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-027.spec.js +6 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-027.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-028.spec.js +6 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-028.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-029.spec.js +48 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-029.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-030.spec.js +6 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-030.spec.js.map +7 -0
- package/dist/modules/channel_imap/acl.js +10 -0
- package/dist/modules/channel_imap/acl.js.map +7 -0
- package/dist/modules/channel_imap/di.js +23 -0
- package/dist/modules/channel_imap/di.js.map +7 -0
- package/dist/modules/channel_imap/index.js +9 -0
- package/dist/modules/channel_imap/index.js.map +7 -0
- package/dist/modules/channel_imap/integration.js +135 -0
- package/dist/modules/channel_imap/integration.js.map +7 -0
- package/dist/modules/channel_imap/lib/adapter.js +291 -0
- package/dist/modules/channel_imap/lib/adapter.js.map +7 -0
- package/dist/modules/channel_imap/lib/capabilities.js +8 -0
- package/dist/modules/channel_imap/lib/capabilities.js.map +7 -0
- package/dist/modules/channel_imap/lib/convert-outbound.js +54 -0
- package/dist/modules/channel_imap/lib/convert-outbound.js.map +7 -0
- package/dist/modules/channel_imap/lib/credentials.js +104 -0
- package/dist/modules/channel_imap/lib/credentials.js.map +7 -0
- package/dist/modules/channel_imap/lib/health.js +39 -0
- package/dist/modules/channel_imap/lib/health.js.map +7 -0
- package/dist/modules/channel_imap/lib/host-pinning.js +34 -0
- package/dist/modules/channel_imap/lib/host-pinning.js.map +7 -0
- package/dist/modules/channel_imap/lib/imap-client.js +210 -0
- package/dist/modules/channel_imap/lib/imap-client.js.map +7 -0
- package/dist/modules/channel_imap/lib/normalize-inbound.js +19 -0
- package/dist/modules/channel_imap/lib/normalize-inbound.js.map +7 -0
- package/dist/modules/channel_imap/lib/smtp-client.js +113 -0
- package/dist/modules/channel_imap/lib/smtp-client.js.map +7 -0
- package/dist/modules/channel_imap/lib/transport.js +17 -0
- package/dist/modules/channel_imap/lib/transport.js.map +7 -0
- package/dist/modules/channel_imap/lib/validate-credentials.js +69 -0
- package/dist/modules/channel_imap/lib/validate-credentials.js.map +7 -0
- package/dist/modules/channel_imap/setup.js +25 -0
- package/dist/modules/channel_imap/setup.js.map +7 -0
- package/dist/modules/channel_imap/widgets/injection/connect/widget.client.js +337 -0
- package/dist/modules/channel_imap/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_imap/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_imap/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_imap/widgets/injection-table.js +14 -0
- package/dist/modules/channel_imap/widgets/injection-table.js.map +7 -0
- package/jest.config.cjs +34 -0
- package/package.json +99 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.ts +80 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-002.spec.ts +28 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-003.spec.ts +23 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-021.spec.ts +40 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-022.spec.ts +38 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-023.spec.ts +31 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-024.spec.ts +27 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-025.spec.ts +23 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-026.spec.ts +18 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-027.spec.ts +18 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-028.spec.ts +19 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-029.spec.ts +72 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-030.spec.ts +19 -0
- package/src/modules/channel_imap/acl.ts +6 -0
- package/src/modules/channel_imap/di.ts +26 -0
- package/src/modules/channel_imap/index.ts +6 -0
- package/src/modules/channel_imap/integration.ts +131 -0
- package/src/modules/channel_imap/lib/__tests__/adapter.test.ts +499 -0
- package/src/modules/channel_imap/lib/__tests__/convert-outbound.test.ts +73 -0
- package/src/modules/channel_imap/lib/__tests__/credentials.test.ts +154 -0
- package/src/modules/channel_imap/lib/__tests__/host-pinning.test.ts +68 -0
- package/src/modules/channel_imap/lib/__tests__/imap-client.test.ts +180 -0
- package/src/modules/channel_imap/lib/__tests__/normalize-inbound.test.ts +126 -0
- package/src/modules/channel_imap/lib/__tests__/transport.test.ts +68 -0
- package/src/modules/channel_imap/lib/__tests__/validate-credentials.test.ts +156 -0
- package/src/modules/channel_imap/lib/adapter.ts +451 -0
- package/src/modules/channel_imap/lib/capabilities.ts +16 -0
- package/src/modules/channel_imap/lib/convert-outbound.ts +79 -0
- package/src/modules/channel_imap/lib/credentials.ts +172 -0
- package/src/modules/channel_imap/lib/health.ts +70 -0
- package/src/modules/channel_imap/lib/host-pinning.ts +59 -0
- package/src/modules/channel_imap/lib/imap-client.ts +382 -0
- package/src/modules/channel_imap/lib/normalize-inbound.ts +47 -0
- package/src/modules/channel_imap/lib/smtp-client.ts +214 -0
- package/src/modules/channel_imap/lib/transport.ts +37 -0
- package/src/modules/channel_imap/lib/validate-credentials.ts +98 -0
- package/src/modules/channel_imap/setup.ts +34 -0
- package/src/modules/channel_imap/widgets/injection/connect/widget.client.tsx +359 -0
- package/src/modules/channel_imap/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_imap/widgets/injection-table.ts +12 -0
- package/tsconfig.json +9 -0
- 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
|
+
}
|