@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,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
|
+
}
|