@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,79 @@
1
+ import type {
2
+ ChannelNativeContent,
3
+ ConvertOutboundInput,
4
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter'
5
+ import {
6
+ htmlToText,
7
+ referencesFromMeta,
8
+ sanitizeHeaderValue,
9
+ stringOrUndefined,
10
+ toAddressList,
11
+ } from '@open-mercato/core/modules/communication_channels/lib/email-mime'
12
+
13
+ /**
14
+ * Convert a hub-canonical outbound payload to an email-shaped `ChannelNativeContent`.
15
+ *
16
+ * Subject and threading headers come from `channelMetadata` populated by the hub:
17
+ * - `subject` (string)
18
+ * - `to` / `cc` / `bcc` (string | string[])
19
+ * - `inReplyTo` (string)
20
+ * - `references` (string[])
21
+ *
22
+ * Body format:
23
+ * - `'html'` keeps the HTML body as-is, derives plain-text via a naive strip.
24
+ * - `'text'` produces text-only.
25
+ * - `'markdown'` is not supported by email; we treat it as text (the hub limits
26
+ * `supportedBodyFormats` to ['text','html'] so this should not occur in practice).
27
+ */
28
+
29
+ export async function convertOutboundForEmail(
30
+ input: ConvertOutboundInput,
31
+ ): Promise<ChannelNativeContent> {
32
+ const meta = (input.channelMetadata ?? {}) as Record<string, unknown>
33
+ // Defense-in-depth: strip CR/LF/tab from every header-shaped field so a crafted
34
+ // subject or recipient cannot smuggle an extra header (e.g. a hidden Bcc),
35
+ // instead of relying solely on the downstream SMTP composer to neutralize it.
36
+ const sanitizeOptionalHeader = (value: string | undefined): string | undefined =>
37
+ value === undefined ? undefined : sanitizeHeaderValue(value)
38
+ const subject = sanitizeOptionalHeader(stringOrUndefined(meta.subject))
39
+ const to = toAddressList(meta.to).map(sanitizeHeaderValue)
40
+ if (to.length === 0) {
41
+ throw new Error('Email outbound conversion requires at least one recipient (channelMetadata.to)')
42
+ }
43
+ const cc = toAddressList(meta.cc).map(sanitizeHeaderValue)
44
+ const bcc = toAddressList(meta.bcc).map(sanitizeHeaderValue)
45
+ const inReplyTo = sanitizeOptionalHeader(stringOrUndefined(meta.inReplyTo))
46
+ const references = referencesFromMeta(meta.references)?.map(sanitizeHeaderValue)
47
+ const messageId = sanitizeOptionalHeader(stringOrUndefined(meta.messageId))
48
+
49
+ const html = input.bodyFormat === 'html' ? input.body : undefined
50
+ const text = input.bodyFormat === 'html' ? htmlToText(input.body) : input.body
51
+
52
+ const native: ChannelNativeContent = {
53
+ content: {
54
+ text,
55
+ html,
56
+ bodyFormat: input.bodyFormat,
57
+ attachments: input.attachments,
58
+ raw: {
59
+ subject,
60
+ to,
61
+ cc,
62
+ bcc,
63
+ inReplyTo,
64
+ references,
65
+ messageId,
66
+ },
67
+ },
68
+ metadata: {
69
+ subject,
70
+ to,
71
+ cc,
72
+ bcc,
73
+ inReplyTo,
74
+ references,
75
+ messageId,
76
+ },
77
+ }
78
+ return native
79
+ }
@@ -0,0 +1,172 @@
1
+ import { z } from 'zod'
2
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
3
+
4
+ /**
5
+ * SSRF guard: reject hostnames that resolve to internal networks. Operators
6
+ * configure their own IMAP/SMTP server, so the host string is attacker-controlled
7
+ * in a per-user-channel context. Blocking these prevents the credential-validation
8
+ * flow from acting as a port scanner or leaking the platform's outbound IP to
9
+ * internal infrastructure (cloud metadata endpoints, kube-apiserver, RDS, etc).
10
+ *
11
+ * The check is string-based: it rejects literal internal IPs, `localhost`, and
12
+ * the obfuscated encodings that exist to evade such filters (IPv4-mapped IPv6,
13
+ * decimal/hex/octal/short-form IPv4, bracketed and expanded IPv6). It does NOT
14
+ * by itself catch a public hostname that resolves — or is DNS-rebound — to a
15
+ * private address; that gap is closed at connect time by `resolveSafeHostAddress`
16
+ * (`host-pinning.ts`), which resolves the host, rejects any internal resolved
17
+ * address, and pins the connection to the validated IP. Operators with a
18
+ * genuinely private IMAP host set `OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true`.
19
+ */
20
+ const FORBIDDEN_HOST_NAMES = new Set([
21
+ 'localhost',
22
+ 'localhost6',
23
+ 'ip6-localhost',
24
+ 'ip6-loopback',
25
+ 'metadata.google.internal',
26
+ ])
27
+
28
+ const PRIVATE_IPV4_PATTERNS: RegExp[] = [
29
+ /^(127|10)\./, // 127/8 loopback, 10/8 private
30
+ /^172\.(1[6-9]|2[0-9]|3[01])\./, // 172.16/12 private
31
+ /^192\.168\./, // 192.168/16 private
32
+ /^169\.254\./, // link-local + cloud metadata (169.254.169.254)
33
+ /^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./, // CGNAT 100.64/10
34
+ /^0\./, // 0.0.0.0/8 reserved
35
+ ]
36
+
37
+ const PRIVATE_IPV6_PATTERNS: RegExp[] = [
38
+ /^::$/, // unspecified
39
+ /^::1$/, // loopback
40
+ /^::ffff:/, // IPv4-mapped (hex-group form; dotted form is unwrapped first)
41
+ /^(fc|fd)[0-9a-f]{0,2}:/, // unique-local fc00::/7
42
+ /^fe80:/, // link-local
43
+ /^(0{1,4}:){7}0{0,3}1$/, // fully-expanded loopback
44
+ /^(0{1,4}:){7}0{1,4}$/, // fully-expanded unspecified
45
+ ]
46
+
47
+ function isDottedDecimalQuad(host: string): boolean {
48
+ const parts = host.split('.')
49
+ return parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
50
+ }
51
+
52
+ /**
53
+ * True when `host` is an obfuscated IPv4 encoding — decimal integer
54
+ * (`2130706433`), hex (`0x7f.0.0.1`), octal (`0177.0.0.1`) or short form
55
+ * (`127.1`). These forms exist almost exclusively to bypass SSRF string filters,
56
+ * so we reject them outright; legitimate operators use a hostname or a standard
57
+ * dotted-decimal quad.
58
+ */
59
+ function isObfuscatedIpv4(host: string): boolean {
60
+ if (host.includes(':')) return false
61
+ if (/^\d+$/.test(host)) return true
62
+ if (/(^|\.)0x[0-9a-f]+/.test(host)) return true
63
+ const labels = host.split('.')
64
+ if (!labels.every((label) => /^[0-9a-f]+$/.test(label))) return false
65
+ if (isDottedDecimalQuad(host) && !labels.some((label) => label.length > 1 && label.startsWith('0'))) return false
66
+ return true
67
+ }
68
+
69
+ function normalizeHost(raw: string): string {
70
+ let host = raw.trim().toLowerCase()
71
+ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1)
72
+ const mappedIpv4 = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
73
+ if (mappedIpv4) return mappedIpv4[1]
74
+ return host
75
+ }
76
+
77
+ /**
78
+ * Classify a host as internal/loopback/metadata, ignoring the operator escape
79
+ * hatch. Exported so the SSRF guard can be asserted directly in unit tests.
80
+ */
81
+ export function isInternalHost(rawHost: string): boolean {
82
+ const host = normalizeHost(rawHost)
83
+ if (!host) return false
84
+ if (FORBIDDEN_HOST_NAMES.has(host) || host.endsWith('.localhost')) return true
85
+ if (host.includes(':')) return PRIVATE_IPV6_PATTERNS.some((pattern) => pattern.test(host))
86
+ if (isObfuscatedIpv4(host)) return true
87
+ // Only treat the private-range patterns as internal for a real dotted-decimal
88
+ // quad. Otherwise a hostname whose first label merely looks like a private
89
+ // range (e.g. `0.mx.example.com`, `10.example.com`) is wrongly rejected.
90
+ // Obfuscated/short IPv4 forms were already caught above, so anything reaching
91
+ // here is either a quad or a genuine hostname.
92
+ return isDottedDecimalQuad(host) && PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(host))
93
+ }
94
+
95
+ function assertSafeHost(host: string, ctx: { addIssue: (issue: { code: 'custom'; message: string }) => void }): void {
96
+ if (parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS, false)) return
97
+ if (!host.trim()) return
98
+ if (isInternalHost(host)) {
99
+ ctx.addIssue({
100
+ code: 'custom',
101
+ message:
102
+ 'Host appears to point at a private or loopback address. If this is intentional, an operator must set OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true.',
103
+ })
104
+ }
105
+ }
106
+
107
+ function hostnameSchema(label: 'IMAP' | 'SMTP') {
108
+ return z
109
+ .string()
110
+ .min(1, `${label} host required`)
111
+ .max(253, `${label} host too long`)
112
+ .superRefine((value, ctx) => assertSafeHost(value, ctx))
113
+ }
114
+
115
+ /**
116
+ * Per-user IMAP+SMTP credentials. Validated whenever a user connects a new
117
+ * channel (`POST /api/communication_channels/channels/connect/credentials`) and
118
+ * before every outbound send / inbound poll.
119
+ *
120
+ * The hub persists this blob inside `IntegrationCredentials.credentials` (encrypted
121
+ * at rest). Do not log credential values; the adapter logs `<redacted>` for any
122
+ * password-shaped key.
123
+ */
124
+ export const imapCredentialsSchema = z
125
+ .object({
126
+ imapHost: hostnameSchema('IMAP'),
127
+ imapPort: z.coerce
128
+ .number()
129
+ .int()
130
+ .min(1, 'IMAP port must be a positive integer')
131
+ .max(65535, 'IMAP port must be <= 65535'),
132
+ imapTls: z.enum(['tls', 'starttls', 'none']),
133
+ imapUser: z.string().min(1, 'IMAP username required'),
134
+ imapPassword: z.string().min(1, 'IMAP password required'),
135
+
136
+ smtpHost: hostnameSchema('SMTP'),
137
+ smtpPort: z.coerce
138
+ .number()
139
+ .int()
140
+ .min(1, 'SMTP port must be a positive integer')
141
+ .max(65535, 'SMTP port must be <= 65535'),
142
+ smtpTls: z.enum(['tls', 'starttls', 'none']),
143
+ smtpUser: z.string().min(1, 'SMTP username required'),
144
+ smtpPassword: z.string().min(1, 'SMTP password required'),
145
+
146
+ fromAddress: z.string().email('From address must be a valid email'),
147
+ })
148
+ // `.passthrough()` (not `.strict()`) so the connect-credential-channel command
149
+ // can stash bookkeeping fields like `userId` alongside the user-entered
150
+ // credentials. Strict was rejecting any extra key with "Unrecognized key" and
151
+ // blocking outbound SMTP after a real user connected via the per-user flow.
152
+ .passthrough()
153
+
154
+ export type ImapCredentials = z.infer<typeof imapCredentialsSchema>
155
+
156
+ /**
157
+ * Internal poll-state stored on `CommunicationChannel.channelState` so we can
158
+ * resume polling without re-scanning the entire mailbox each tick.
159
+ *
160
+ * uidValidity — IMAP UIDVALIDITY for INBOX; if it changes we must full-resync.
161
+ * uidNext — UIDNEXT for INBOX; subsequent polls fetch `<previous uidNext>:*`.
162
+ */
163
+ export const imapChannelStateSchema = z
164
+ .object({
165
+ uidValidity: z.union([z.number(), z.string()]).optional(),
166
+ uidNext: z.union([z.number(), z.string()]).optional(),
167
+ lastFolder: z.string().optional(),
168
+ })
169
+ .partial()
170
+ .passthrough()
171
+
172
+ export type ImapChannelState = z.infer<typeof imapChannelStateSchema>
@@ -0,0 +1,70 @@
1
+ import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
2
+ import { imapCredentialsSchema } from './credentials'
3
+ import { credentialsToConnection, getImapClient } from './imap-client'
4
+
5
+ export type HealthCheckStatus = 'healthy' | 'degraded' | 'unhealthy'
6
+
7
+ export interface HealthCheckResult {
8
+ status: HealthCheckStatus
9
+ message?: string
10
+ details?: Record<string, unknown>
11
+ }
12
+
13
+ /**
14
+ * Liveness probe for the IMAP/SMTP integration. The hub resolves it by the
15
+ * service name declared in `integration.ts` (`channelImapHealthCheck`) and
16
+ * passes the tenant/user-scoped `IntegrationCredentials` row — the full
17
+ * IMAP+SMTP connection blob.
18
+ *
19
+ * Unlike the OAuth channels, IMAP credentials carry everything needed for a
20
+ * real probe, so we do a cheap LOGIN: open the IMAP connection, read
21
+ * capabilities, log out. We deliberately probe IMAP only (the inbound side) and
22
+ * skip the SMTP `verify` round-trip to keep the check cheap. The probe passes a
23
+ * tighter 8s connect/greeting timeout (below the hub's 10s health-check budget)
24
+ * so a slow/unreachable host fails fast as `unhealthy` here with an actionable
25
+ * reason, rather than losing the race to the hub's generic timeout; polling is
26
+ * unaffected (it uses the default 15s connect + 60s socket timeouts).
27
+ * Auth/connection failures surface as `unhealthy`; a clean LOGIN is `healthy`.
28
+ */
29
+ export const channelImapHealthCheck = {
30
+ async check(
31
+ credentials: Record<string, unknown> | null,
32
+ _scope: IntegrationScope,
33
+ ): Promise<HealthCheckResult> {
34
+ const parsed = imapCredentialsSchema.safeParse(credentials ?? {})
35
+ if (!parsed.success) {
36
+ const first = parsed.error.issues[0]
37
+ return {
38
+ status: 'unhealthy',
39
+ message: `IMAP credentials invalid: ${first?.message ?? 'unknown validation error'}`,
40
+ details: { reason: 'invalid_credentials' },
41
+ }
42
+ }
43
+ try {
44
+ const result = await getImapClient().connectAndValidate({
45
+ ...credentialsToConnection(parsed.data),
46
+ connectTimeoutMs: 8_000,
47
+ })
48
+ return {
49
+ status: 'healthy',
50
+ message: 'IMAP login succeeded',
51
+ details: { capabilities: result.capabilities },
52
+ }
53
+ } catch (error) {
54
+ const raw = error instanceof Error ? error.message : 'IMAP login failed'
55
+ // Strip the internal-only marker so a policy/diagnostic string never
56
+ // reaches an operator-facing health message, and distinguish a
57
+ // transport-policy rejection (cleartext not opted in) from a real login
58
+ // failure so operators get an actionable reason code.
59
+ const message = raw.replace(/^\[internal\]\s*/, '')
60
+ const isTransportPolicy = /cleartext transport/i.test(message)
61
+ return {
62
+ status: 'unhealthy',
63
+ message: isTransportPolicy
64
+ ? `IMAP transport not allowed: ${message}`
65
+ : `IMAP login failed: ${message}`,
66
+ details: { reason: isTransportPolicy ? 'insecure_transport' : 'imap_login_failed' },
67
+ }
68
+ }
69
+ },
70
+ }
@@ -0,0 +1,59 @@
1
+ import { lookup as dnsLookup } from 'node:dns/promises'
2
+ import { isIP } from 'node:net'
3
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
4
+ import { isInternalHost } from './credentials'
5
+
6
+ export type HostLookup = (hostname: string) => Promise<Array<{ address: string; family: number }>>
7
+
8
+ const INTERNAL_RESOLVED_MESSAGE =
9
+ 'Host resolves to a private or loopback address. If this is intentional, an operator must set OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true.'
10
+
11
+ const UNRESOLVABLE_MESSAGE = 'Host did not resolve to any address.'
12
+
13
+ function stripBrackets(host: string): string {
14
+ return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
15
+ }
16
+
17
+ /**
18
+ * Resolve `host` to an IP and assert every resolved address is public, then pin
19
+ * the connection to that IP. Closes the DNS-rebinding gap the string-only SSRF
20
+ * guard (`isInternalHost`) leaves open: a public hostname that resolves — or is
21
+ * rebound between validation and connect — to an internal address is rejected
22
+ * here, and the returned IP is what the caller actually connects to (no second
23
+ * lookup the attacker could race).
24
+ *
25
+ * - Literal IPs (already SSRF-checked by the credential schema) are returned
26
+ * unchanged with no `servername`.
27
+ * - Hostnames are resolved to every A/AAAA record; if ANY resolved address is
28
+ * internal the call throws. The validated IP is returned as `host` and the
29
+ * original hostname as `servername`, so TLS SNI + certificate hostname
30
+ * verification still target the real host even though we dial the IP.
31
+ * - Honors `OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS`: when set, resolution is
32
+ * skipped and the host is used verbatim (operators with a genuinely internal
33
+ * mail server).
34
+ */
35
+ export async function resolveSafeHostAddress(
36
+ host: string,
37
+ options: { lookup?: HostLookup } = {},
38
+ ): Promise<{ host: string; servername?: string }> {
39
+ const trimmed = host.trim()
40
+ if (parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS, false)) {
41
+ return { host: trimmed }
42
+ }
43
+ if (isIP(stripBrackets(trimmed)) !== 0) {
44
+ if (isInternalHost(trimmed)) throw new Error(`[internal] ${INTERNAL_RESOLVED_MESSAGE}`)
45
+ return { host: trimmed }
46
+ }
47
+ const resolve =
48
+ options.lookup ?? ((hostname: string) => dnsLookup(hostname, { all: true, verbatim: true }))
49
+ const records = await resolve(trimmed)
50
+ if (!Array.isArray(records) || records.length === 0) {
51
+ throw new Error(`[internal] ${UNRESOLVABLE_MESSAGE}`)
52
+ }
53
+ for (const record of records) {
54
+ if (isInternalHost(record.address)) {
55
+ throw new Error(`[internal] ${INTERNAL_RESOLVED_MESSAGE}`)
56
+ }
57
+ }
58
+ return { host: records[0].address, servername: trimmed }
59
+ }