@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,104 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
|
|
3
|
+
const FORBIDDEN_HOST_NAMES = /* @__PURE__ */ new Set([
|
|
4
|
+
"localhost",
|
|
5
|
+
"localhost6",
|
|
6
|
+
"ip6-localhost",
|
|
7
|
+
"ip6-loopback",
|
|
8
|
+
"metadata.google.internal"
|
|
9
|
+
]);
|
|
10
|
+
const PRIVATE_IPV4_PATTERNS = [
|
|
11
|
+
/^(127|10)\./,
|
|
12
|
+
// 127/8 loopback, 10/8 private
|
|
13
|
+
/^172\.(1[6-9]|2[0-9]|3[01])\./,
|
|
14
|
+
// 172.16/12 private
|
|
15
|
+
/^192\.168\./,
|
|
16
|
+
// 192.168/16 private
|
|
17
|
+
/^169\.254\./,
|
|
18
|
+
// link-local + cloud metadata (169.254.169.254)
|
|
19
|
+
/^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./,
|
|
20
|
+
// CGNAT 100.64/10
|
|
21
|
+
/^0\./
|
|
22
|
+
// 0.0.0.0/8 reserved
|
|
23
|
+
];
|
|
24
|
+
const PRIVATE_IPV6_PATTERNS = [
|
|
25
|
+
/^::$/,
|
|
26
|
+
// unspecified
|
|
27
|
+
/^::1$/,
|
|
28
|
+
// loopback
|
|
29
|
+
/^::ffff:/,
|
|
30
|
+
// IPv4-mapped (hex-group form; dotted form is unwrapped first)
|
|
31
|
+
/^(fc|fd)[0-9a-f]{0,2}:/,
|
|
32
|
+
// unique-local fc00::/7
|
|
33
|
+
/^fe80:/,
|
|
34
|
+
// link-local
|
|
35
|
+
/^(0{1,4}:){7}0{0,3}1$/,
|
|
36
|
+
// fully-expanded loopback
|
|
37
|
+
/^(0{1,4}:){7}0{1,4}$/
|
|
38
|
+
// fully-expanded unspecified
|
|
39
|
+
];
|
|
40
|
+
function isDottedDecimalQuad(host) {
|
|
41
|
+
const parts = host.split(".");
|
|
42
|
+
return parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
43
|
+
}
|
|
44
|
+
function isObfuscatedIpv4(host) {
|
|
45
|
+
if (host.includes(":")) return false;
|
|
46
|
+
if (/^\d+$/.test(host)) return true;
|
|
47
|
+
if (/(^|\.)0x[0-9a-f]+/.test(host)) return true;
|
|
48
|
+
const labels = host.split(".");
|
|
49
|
+
if (!labels.every((label) => /^[0-9a-f]+$/.test(label))) return false;
|
|
50
|
+
if (isDottedDecimalQuad(host) && !labels.some((label) => label.length > 1 && label.startsWith("0"))) return false;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
function normalizeHost(raw) {
|
|
54
|
+
let host = raw.trim().toLowerCase();
|
|
55
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
56
|
+
const mappedIpv4 = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
57
|
+
if (mappedIpv4) return mappedIpv4[1];
|
|
58
|
+
return host;
|
|
59
|
+
}
|
|
60
|
+
function isInternalHost(rawHost) {
|
|
61
|
+
const host = normalizeHost(rawHost);
|
|
62
|
+
if (!host) return false;
|
|
63
|
+
if (FORBIDDEN_HOST_NAMES.has(host) || host.endsWith(".localhost")) return true;
|
|
64
|
+
if (host.includes(":")) return PRIVATE_IPV6_PATTERNS.some((pattern) => pattern.test(host));
|
|
65
|
+
if (isObfuscatedIpv4(host)) return true;
|
|
66
|
+
return isDottedDecimalQuad(host) && PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(host));
|
|
67
|
+
}
|
|
68
|
+
function assertSafeHost(host, ctx) {
|
|
69
|
+
if (parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS, false)) return;
|
|
70
|
+
if (!host.trim()) return;
|
|
71
|
+
if (isInternalHost(host)) {
|
|
72
|
+
ctx.addIssue({
|
|
73
|
+
code: "custom",
|
|
74
|
+
message: "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."
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function hostnameSchema(label) {
|
|
79
|
+
return z.string().min(1, `${label} host required`).max(253, `${label} host too long`).superRefine((value, ctx) => assertSafeHost(value, ctx));
|
|
80
|
+
}
|
|
81
|
+
const imapCredentialsSchema = z.object({
|
|
82
|
+
imapHost: hostnameSchema("IMAP"),
|
|
83
|
+
imapPort: z.coerce.number().int().min(1, "IMAP port must be a positive integer").max(65535, "IMAP port must be <= 65535"),
|
|
84
|
+
imapTls: z.enum(["tls", "starttls", "none"]),
|
|
85
|
+
imapUser: z.string().min(1, "IMAP username required"),
|
|
86
|
+
imapPassword: z.string().min(1, "IMAP password required"),
|
|
87
|
+
smtpHost: hostnameSchema("SMTP"),
|
|
88
|
+
smtpPort: z.coerce.number().int().min(1, "SMTP port must be a positive integer").max(65535, "SMTP port must be <= 65535"),
|
|
89
|
+
smtpTls: z.enum(["tls", "starttls", "none"]),
|
|
90
|
+
smtpUser: z.string().min(1, "SMTP username required"),
|
|
91
|
+
smtpPassword: z.string().min(1, "SMTP password required"),
|
|
92
|
+
fromAddress: z.string().email("From address must be a valid email")
|
|
93
|
+
}).passthrough();
|
|
94
|
+
const imapChannelStateSchema = z.object({
|
|
95
|
+
uidValidity: z.union([z.number(), z.string()]).optional(),
|
|
96
|
+
uidNext: z.union([z.number(), z.string()]).optional(),
|
|
97
|
+
lastFolder: z.string().optional()
|
|
98
|
+
}).partial().passthrough();
|
|
99
|
+
export {
|
|
100
|
+
imapChannelStateSchema,
|
|
101
|
+
imapCredentialsSchema,
|
|
102
|
+
isInternalHost
|
|
103
|
+
};
|
|
104
|
+
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_imap/lib/credentials.ts"],
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\n\n/**\n * SSRF guard: reject hostnames that resolve to internal networks. Operators\n * configure their own IMAP/SMTP server, so the host string is attacker-controlled\n * in a per-user-channel context. Blocking these prevents the credential-validation\n * flow from acting as a port scanner or leaking the platform's outbound IP to\n * internal infrastructure (cloud metadata endpoints, kube-apiserver, RDS, etc).\n *\n * The check is string-based: it rejects literal internal IPs, `localhost`, and\n * the obfuscated encodings that exist to evade such filters (IPv4-mapped IPv6,\n * decimal/hex/octal/short-form IPv4, bracketed and expanded IPv6). It does NOT\n * by itself catch a public hostname that resolves \u2014 or is DNS-rebound \u2014 to a\n * private address; that gap is closed at connect time by `resolveSafeHostAddress`\n * (`host-pinning.ts`), which resolves the host, rejects any internal resolved\n * address, and pins the connection to the validated IP. Operators with a\n * genuinely private IMAP host set `OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true`.\n */\nconst FORBIDDEN_HOST_NAMES = new Set([\n 'localhost',\n 'localhost6',\n 'ip6-localhost',\n 'ip6-loopback',\n 'metadata.google.internal',\n])\n\nconst PRIVATE_IPV4_PATTERNS: RegExp[] = [\n /^(127|10)\\./, // 127/8 loopback, 10/8 private\n /^172\\.(1[6-9]|2[0-9]|3[01])\\./, // 172.16/12 private\n /^192\\.168\\./, // 192.168/16 private\n /^169\\.254\\./, // link-local + cloud metadata (169.254.169.254)\n /^100\\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\\./, // CGNAT 100.64/10\n /^0\\./, // 0.0.0.0/8 reserved\n]\n\nconst PRIVATE_IPV6_PATTERNS: RegExp[] = [\n /^::$/, // unspecified\n /^::1$/, // loopback\n /^::ffff:/, // IPv4-mapped (hex-group form; dotted form is unwrapped first)\n /^(fc|fd)[0-9a-f]{0,2}:/, // unique-local fc00::/7\n /^fe80:/, // link-local\n /^(0{1,4}:){7}0{0,3}1$/, // fully-expanded loopback\n /^(0{1,4}:){7}0{1,4}$/, // fully-expanded unspecified\n]\n\nfunction isDottedDecimalQuad(host: string): boolean {\n const parts = host.split('.')\n return parts.length === 4 && parts.every((part) => /^\\d{1,3}$/.test(part) && Number(part) <= 255)\n}\n\n/**\n * True when `host` is an obfuscated IPv4 encoding \u2014 decimal integer\n * (`2130706433`), hex (`0x7f.0.0.1`), octal (`0177.0.0.1`) or short form\n * (`127.1`). These forms exist almost exclusively to bypass SSRF string filters,\n * so we reject them outright; legitimate operators use a hostname or a standard\n * dotted-decimal quad.\n */\nfunction isObfuscatedIpv4(host: string): boolean {\n if (host.includes(':')) return false\n if (/^\\d+$/.test(host)) return true\n if (/(^|\\.)0x[0-9a-f]+/.test(host)) return true\n const labels = host.split('.')\n if (!labels.every((label) => /^[0-9a-f]+$/.test(label))) return false\n if (isDottedDecimalQuad(host) && !labels.some((label) => label.length > 1 && label.startsWith('0'))) return false\n return true\n}\n\nfunction normalizeHost(raw: string): string {\n let host = raw.trim().toLowerCase()\n if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1)\n const mappedIpv4 = host.match(/^::ffff:(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3})$/)\n if (mappedIpv4) return mappedIpv4[1]\n return host\n}\n\n/**\n * Classify a host as internal/loopback/metadata, ignoring the operator escape\n * hatch. Exported so the SSRF guard can be asserted directly in unit tests.\n */\nexport function isInternalHost(rawHost: string): boolean {\n const host = normalizeHost(rawHost)\n if (!host) return false\n if (FORBIDDEN_HOST_NAMES.has(host) || host.endsWith('.localhost')) return true\n if (host.includes(':')) return PRIVATE_IPV6_PATTERNS.some((pattern) => pattern.test(host))\n if (isObfuscatedIpv4(host)) return true\n // Only treat the private-range patterns as internal for a real dotted-decimal\n // quad. Otherwise a hostname whose first label merely looks like a private\n // range (e.g. `0.mx.example.com`, `10.example.com`) is wrongly rejected.\n // Obfuscated/short IPv4 forms were already caught above, so anything reaching\n // here is either a quad or a genuine hostname.\n return isDottedDecimalQuad(host) && PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(host))\n}\n\nfunction assertSafeHost(host: string, ctx: { addIssue: (issue: { code: 'custom'; message: string }) => void }): void {\n if (parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS, false)) return\n if (!host.trim()) return\n if (isInternalHost(host)) {\n ctx.addIssue({\n code: 'custom',\n message:\n '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.',\n })\n }\n}\n\nfunction hostnameSchema(label: 'IMAP' | 'SMTP') {\n return z\n .string()\n .min(1, `${label} host required`)\n .max(253, `${label} host too long`)\n .superRefine((value, ctx) => assertSafeHost(value, ctx))\n}\n\n/**\n * Per-user IMAP+SMTP credentials. Validated whenever a user connects a new\n * channel (`POST /api/communication_channels/channels/connect/credentials`) and\n * before every outbound send / inbound poll.\n *\n * The hub persists this blob inside `IntegrationCredentials.credentials` (encrypted\n * at rest). Do not log credential values; the adapter logs `<redacted>` for any\n * password-shaped key.\n */\nexport const imapCredentialsSchema = z\n .object({\n imapHost: hostnameSchema('IMAP'),\n imapPort: z.coerce\n .number()\n .int()\n .min(1, 'IMAP port must be a positive integer')\n .max(65535, 'IMAP port must be <= 65535'),\n imapTls: z.enum(['tls', 'starttls', 'none']),\n imapUser: z.string().min(1, 'IMAP username required'),\n imapPassword: z.string().min(1, 'IMAP password required'),\n\n smtpHost: hostnameSchema('SMTP'),\n smtpPort: z.coerce\n .number()\n .int()\n .min(1, 'SMTP port must be a positive integer')\n .max(65535, 'SMTP port must be <= 65535'),\n smtpTls: z.enum(['tls', 'starttls', 'none']),\n smtpUser: z.string().min(1, 'SMTP username required'),\n smtpPassword: z.string().min(1, 'SMTP password required'),\n\n fromAddress: z.string().email('From address must be a valid email'),\n })\n // `.passthrough()` (not `.strict()`) so the connect-credential-channel command\n // can stash bookkeeping fields like `userId` alongside the user-entered\n // credentials. Strict was rejecting any extra key with \"Unrecognized key\" and\n // blocking outbound SMTP after a real user connected via the per-user flow.\n .passthrough()\n\nexport type ImapCredentials = z.infer<typeof imapCredentialsSchema>\n\n/**\n * Internal poll-state stored on `CommunicationChannel.channelState` so we can\n * resume polling without re-scanning the entire mailbox each tick.\n *\n * uidValidity \u2014 IMAP UIDVALIDITY for INBOX; if it changes we must full-resync.\n * uidNext \u2014 UIDNEXT for INBOX; subsequent polls fetch `<previous uidNext>:*`.\n */\nexport const imapChannelStateSchema = z\n .object({\n uidValidity: z.union([z.number(), z.string()]).optional(),\n uidNext: z.union([z.number(), z.string()]).optional(),\n lastFolder: z.string().optional(),\n })\n .partial()\n .passthrough()\n\nexport type ImapChannelState = z.infer<typeof imapChannelStateSchema>\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { imapCredentialsSchema } from "./credentials.js";
|
|
2
|
+
import { credentialsToConnection, getImapClient } from "./imap-client.js";
|
|
3
|
+
const channelImapHealthCheck = {
|
|
4
|
+
async check(credentials, _scope) {
|
|
5
|
+
const parsed = imapCredentialsSchema.safeParse(credentials ?? {});
|
|
6
|
+
if (!parsed.success) {
|
|
7
|
+
const first = parsed.error.issues[0];
|
|
8
|
+
return {
|
|
9
|
+
status: "unhealthy",
|
|
10
|
+
message: `IMAP credentials invalid: ${first?.message ?? "unknown validation error"}`,
|
|
11
|
+
details: { reason: "invalid_credentials" }
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const result = await getImapClient().connectAndValidate({
|
|
16
|
+
...credentialsToConnection(parsed.data),
|
|
17
|
+
connectTimeoutMs: 8e3
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
status: "healthy",
|
|
21
|
+
message: "IMAP login succeeded",
|
|
22
|
+
details: { capabilities: result.capabilities }
|
|
23
|
+
};
|
|
24
|
+
} catch (error) {
|
|
25
|
+
const raw = error instanceof Error ? error.message : "IMAP login failed";
|
|
26
|
+
const message = raw.replace(/^\[internal\]\s*/, "");
|
|
27
|
+
const isTransportPolicy = /cleartext transport/i.test(message);
|
|
28
|
+
return {
|
|
29
|
+
status: "unhealthy",
|
|
30
|
+
message: isTransportPolicy ? `IMAP transport not allowed: ${message}` : `IMAP login failed: ${message}`,
|
|
31
|
+
details: { reason: isTransportPolicy ? "insecure_transport" : "imap_login_failed" }
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
export {
|
|
37
|
+
channelImapHealthCheck
|
|
38
|
+
};
|
|
39
|
+
//# sourceMappingURL=health.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_imap/lib/health.ts"],
|
|
4
|
+
"sourcesContent": ["import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'\nimport { imapCredentialsSchema } from './credentials'\nimport { credentialsToConnection, getImapClient } from './imap-client'\n\nexport type HealthCheckStatus = 'healthy' | 'degraded' | 'unhealthy'\n\nexport interface HealthCheckResult {\n status: HealthCheckStatus\n message?: string\n details?: Record<string, unknown>\n}\n\n/**\n * Liveness probe for the IMAP/SMTP integration. The hub resolves it by the\n * service name declared in `integration.ts` (`channelImapHealthCheck`) and\n * passes the tenant/user-scoped `IntegrationCredentials` row \u2014 the full\n * IMAP+SMTP connection blob.\n *\n * Unlike the OAuth channels, IMAP credentials carry everything needed for a\n * real probe, so we do a cheap LOGIN: open the IMAP connection, read\n * capabilities, log out. We deliberately probe IMAP only (the inbound side) and\n * skip the SMTP `verify` round-trip to keep the check cheap. The probe passes a\n * tighter 8s connect/greeting timeout (below the hub's 10s health-check budget)\n * so a slow/unreachable host fails fast as `unhealthy` here with an actionable\n * reason, rather than losing the race to the hub's generic timeout; polling is\n * unaffected (it uses the default 15s connect + 60s socket timeouts).\n * Auth/connection failures surface as `unhealthy`; a clean LOGIN is `healthy`.\n */\nexport const channelImapHealthCheck = {\n async check(\n credentials: Record<string, unknown> | null,\n _scope: IntegrationScope,\n ): Promise<HealthCheckResult> {\n const parsed = imapCredentialsSchema.safeParse(credentials ?? {})\n if (!parsed.success) {\n const first = parsed.error.issues[0]\n return {\n status: 'unhealthy',\n message: `IMAP credentials invalid: ${first?.message ?? 'unknown validation error'}`,\n details: { reason: 'invalid_credentials' },\n }\n }\n try {\n const result = await getImapClient().connectAndValidate({\n ...credentialsToConnection(parsed.data),\n connectTimeoutMs: 8_000,\n })\n return {\n status: 'healthy',\n message: 'IMAP login succeeded',\n details: { capabilities: result.capabilities },\n }\n } catch (error) {\n const raw = error instanceof Error ? error.message : 'IMAP login failed'\n // Strip the internal-only marker so a policy/diagnostic string never\n // reaches an operator-facing health message, and distinguish a\n // transport-policy rejection (cleartext not opted in) from a real login\n // failure so operators get an actionable reason code.\n const message = raw.replace(/^\\[internal\\]\\s*/, '')\n const isTransportPolicy = /cleartext transport/i.test(message)\n return {\n status: 'unhealthy',\n message: isTransportPolicy\n ? `IMAP transport not allowed: ${message}`\n : `IMAP login failed: ${message}`,\n details: { reason: isTransportPolicy ? 'insecure_transport' : 'imap_login_failed' },\n }\n }\n },\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,6BAA6B;AACtC,SAAS,yBAAyB,qBAAqB;AA0BhD,MAAM,yBAAyB;AAAA,EACpC,MAAM,MACJ,aACA,QAC4B;AAC5B,UAAM,SAAS,sBAAsB,UAAU,eAAe,CAAC,CAAC;AAChE,QAAI,CAAC,OAAO,SAAS;AACnB,YAAM,QAAQ,OAAO,MAAM,OAAO,CAAC;AACnC,aAAO;AAAA,QACL,QAAQ;AAAA,QACR,SAAS,6BAA6B,OAAO,WAAW,0BAA0B;AAAA,QAClF,SAAS,EAAE,QAAQ,sBAAsB;AAAA,MAC3C;AAAA,IACF;AACA,QAAI;AACF,YAAM,SAAS,MAAM,cAAc,EAAE,mBAAmB;AAAA,QACtD,GAAG,wBAAwB,OAAO,IAAI;AAAA,QACtC,kBAAkB;AAAA,MACpB,CAAC;AACD,aAAO;AAAA,QACL,QAAQ;AAAA,QACR,SAAS;AAAA,QACT,SAAS,EAAE,cAAc,OAAO,aAAa;AAAA,MAC/C;AAAA,IACF,SAAS,OAAO;AACd,YAAM,MAAM,iBAAiB,QAAQ,MAAM,UAAU;AAKrD,YAAM,UAAU,IAAI,QAAQ,oBAAoB,EAAE;AAClD,YAAM,oBAAoB,uBAAuB,KAAK,OAAO;AAC7D,aAAO;AAAA,QACL,QAAQ;AAAA,QACR,SAAS,oBACL,+BAA+B,OAAO,KACtC,sBAAsB,OAAO;AAAA,QACjC,SAAS,EAAE,QAAQ,oBAAoB,uBAAuB,oBAAoB;AAAA,MACpF;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
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.js";
|
|
5
|
+
const INTERNAL_RESOLVED_MESSAGE = "Host resolves to a private or loopback address. If this is intentional, an operator must set OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true.";
|
|
6
|
+
const UNRESOLVABLE_MESSAGE = "Host did not resolve to any address.";
|
|
7
|
+
function stripBrackets(host) {
|
|
8
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
9
|
+
}
|
|
10
|
+
async function resolveSafeHostAddress(host, options = {}) {
|
|
11
|
+
const trimmed = host.trim();
|
|
12
|
+
if (parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS, false)) {
|
|
13
|
+
return { host: trimmed };
|
|
14
|
+
}
|
|
15
|
+
if (isIP(stripBrackets(trimmed)) !== 0) {
|
|
16
|
+
if (isInternalHost(trimmed)) throw new Error(`[internal] ${INTERNAL_RESOLVED_MESSAGE}`);
|
|
17
|
+
return { host: trimmed };
|
|
18
|
+
}
|
|
19
|
+
const resolve = options.lookup ?? ((hostname) => dnsLookup(hostname, { all: true, verbatim: true }));
|
|
20
|
+
const records = await resolve(trimmed);
|
|
21
|
+
if (!Array.isArray(records) || records.length === 0) {
|
|
22
|
+
throw new Error(`[internal] ${UNRESOLVABLE_MESSAGE}`);
|
|
23
|
+
}
|
|
24
|
+
for (const record of records) {
|
|
25
|
+
if (isInternalHost(record.address)) {
|
|
26
|
+
throw new Error(`[internal] ${INTERNAL_RESOLVED_MESSAGE}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { host: records[0].address, servername: trimmed };
|
|
30
|
+
}
|
|
31
|
+
export {
|
|
32
|
+
resolveSafeHostAddress
|
|
33
|
+
};
|
|
34
|
+
//# sourceMappingURL=host-pinning.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_imap/lib/host-pinning.ts"],
|
|
4
|
+
"sourcesContent": ["import { lookup as dnsLookup } from 'node:dns/promises'\nimport { isIP } from 'node:net'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { isInternalHost } from './credentials'\n\nexport type HostLookup = (hostname: string) => Promise<Array<{ address: string; family: number }>>\n\nconst INTERNAL_RESOLVED_MESSAGE =\n 'Host resolves to a private or loopback address. If this is intentional, an operator must set OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true.'\n\nconst UNRESOLVABLE_MESSAGE = 'Host did not resolve to any address.'\n\nfunction stripBrackets(host: string): string {\n return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host\n}\n\n/**\n * Resolve `host` to an IP and assert every resolved address is public, then pin\n * the connection to that IP. Closes the DNS-rebinding gap the string-only SSRF\n * guard (`isInternalHost`) leaves open: a public hostname that resolves \u2014 or is\n * rebound between validation and connect \u2014 to an internal address is rejected\n * here, and the returned IP is what the caller actually connects to (no second\n * lookup the attacker could race).\n *\n * - Literal IPs (already SSRF-checked by the credential schema) are returned\n * unchanged with no `servername`.\n * - Hostnames are resolved to every A/AAAA record; if ANY resolved address is\n * internal the call throws. The validated IP is returned as `host` and the\n * original hostname as `servername`, so TLS SNI + certificate hostname\n * verification still target the real host even though we dial the IP.\n * - Honors `OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS`: when set, resolution is\n * skipped and the host is used verbatim (operators with a genuinely internal\n * mail server).\n */\nexport async function resolveSafeHostAddress(\n host: string,\n options: { lookup?: HostLookup } = {},\n): Promise<{ host: string; servername?: string }> {\n const trimmed = host.trim()\n if (parseBooleanWithDefault(process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS, false)) {\n return { host: trimmed }\n }\n if (isIP(stripBrackets(trimmed)) !== 0) {\n if (isInternalHost(trimmed)) throw new Error(`[internal] ${INTERNAL_RESOLVED_MESSAGE}`)\n return { host: trimmed }\n }\n const resolve =\n options.lookup ?? ((hostname: string) => dnsLookup(hostname, { all: true, verbatim: true }))\n const records = await resolve(trimmed)\n if (!Array.isArray(records) || records.length === 0) {\n throw new Error(`[internal] ${UNRESOLVABLE_MESSAGE}`)\n }\n for (const record of records) {\n if (isInternalHost(record.address)) {\n throw new Error(`[internal] ${INTERNAL_RESOLVED_MESSAGE}`)\n }\n }\n return { host: records[0].address, servername: trimmed }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,UAAU,iBAAiB;AACpC,SAAS,YAAY;AACrB,SAAS,+BAA+B;AACxC,SAAS,sBAAsB;AAI/B,MAAM,4BACJ;AAEF,MAAM,uBAAuB;AAE7B,SAAS,cAAc,MAAsB;AAC3C,SAAO,KAAK,WAAW,GAAG,KAAK,KAAK,SAAS,GAAG,IAAI,KAAK,MAAM,GAAG,EAAE,IAAI;AAC1E;AAoBA,eAAsB,uBACpB,MACA,UAAmC,CAAC,GACY;AAChD,QAAM,UAAU,KAAK,KAAK;AAC1B,MAAI,wBAAwB,QAAQ,IAAI,sCAAsC,KAAK,GAAG;AACpF,WAAO,EAAE,MAAM,QAAQ;AAAA,EACzB;AACA,MAAI,KAAK,cAAc,OAAO,CAAC,MAAM,GAAG;AACtC,QAAI,eAAe,OAAO,EAAG,OAAM,IAAI,MAAM,cAAc,yBAAyB,EAAE;AACtF,WAAO,EAAE,MAAM,QAAQ;AAAA,EACzB;AACA,QAAM,UACJ,QAAQ,WAAW,CAAC,aAAqB,UAAU,UAAU,EAAE,KAAK,MAAM,UAAU,KAAK,CAAC;AAC5F,QAAM,UAAU,MAAM,QAAQ,OAAO;AACrC,MAAI,CAAC,MAAM,QAAQ,OAAO,KAAK,QAAQ,WAAW,GAAG;AACnD,UAAM,IAAI,MAAM,cAAc,oBAAoB,EAAE;AAAA,EACtD;AACA,aAAW,UAAU,SAAS;AAC5B,QAAI,eAAe,OAAO,OAAO,GAAG;AAClC,YAAM,IAAI,MAAM,cAAc,yBAAyB,EAAE;AAAA,IAC3D;AAAA,EACF;AACA,SAAO,EAAE,MAAM,QAAQ,CAAC,EAAE,SAAS,YAAY,QAAQ;AACzD;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { resolveSafeHostAddress } from "./host-pinning.js";
|
|
2
|
+
import { assertTransportAllowed } from "./transport.js";
|
|
3
|
+
class ImapflowClient {
|
|
4
|
+
async openConnection(options) {
|
|
5
|
+
const { ImapFlow } = await loadImapFlow();
|
|
6
|
+
const pinned = await resolveSafeHostAddress(options.host);
|
|
7
|
+
const client = new ImapFlow({
|
|
8
|
+
host: pinned.host,
|
|
9
|
+
port: options.port,
|
|
10
|
+
secure: options.transport === "tls",
|
|
11
|
+
auth: { user: options.user, pass: options.pass },
|
|
12
|
+
// Enforce certificate verification on every encrypted transport (implicit
|
|
13
|
+
// TLS and STARTTLS). Only cleartext ('none', gated behind an env opt-in)
|
|
14
|
+
// omits the TLS options. Mirrors the SMTP client so an upstream default
|
|
15
|
+
// change can't silently disable cert checks.
|
|
16
|
+
tls: options.transport === "none" ? void 0 : { rejectUnauthorized: true, ...pinned.servername ? { servername: pinned.servername } : {} },
|
|
17
|
+
logger: false,
|
|
18
|
+
// Gmail's IMAP can take 15-30s to respond to NAMESPACE under load even
|
|
19
|
+
// after a successful AUTHENTICATE — observed during demo with valid
|
|
20
|
+
// credentials and clean TLS. A 10s socket timeout aborts the command
|
|
21
|
+
// mid-stream and surfaces as "NoConnection"/"Unexpected close" to the
|
|
22
|
+
// worker, which then marks the channel as 'error'. 60s is enough for
|
|
23
|
+
// any reasonable IMAP server while still bailing on truly dead hosts.
|
|
24
|
+
socketTimeout: options.timeoutMs ?? 6e4,
|
|
25
|
+
// Initial TCP+TLS handshake is usually fast; cap at 15s so a non-responsive
|
|
26
|
+
// host bails before the UI flow stalls. Greeting can be slow on some
|
|
27
|
+
// providers (Gmail occasionally takes 5-10s), so allow 15s there too.
|
|
28
|
+
connectionTimeout: options.connectTimeoutMs ?? 15e3,
|
|
29
|
+
greetingTimeout: options.connectTimeoutMs ?? 15e3
|
|
30
|
+
});
|
|
31
|
+
const eventClient = client;
|
|
32
|
+
if (typeof eventClient.on === "function") {
|
|
33
|
+
eventClient.on("error", () => {
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
await client.connect();
|
|
37
|
+
if (options.transport === "starttls") {
|
|
38
|
+
const secured = client.secureConnection === true;
|
|
39
|
+
if (!secured) {
|
|
40
|
+
await client.logout().catch(() => void 0);
|
|
41
|
+
throw new Error(
|
|
42
|
+
"IMAP server did not advertise STARTTLS \u2014 cannot authenticate over cleartext. Switch transport to tls (port 993) or contact the mailbox provider."
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return client;
|
|
47
|
+
}
|
|
48
|
+
async connectAndValidate(options) {
|
|
49
|
+
const client = await this.openConnection(options);
|
|
50
|
+
try {
|
|
51
|
+
const capabilityKeys = extractCapabilityKeys(client);
|
|
52
|
+
return { capabilities: capabilityKeys };
|
|
53
|
+
} finally {
|
|
54
|
+
await client.logout().catch(() => void 0);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async selectInbox(options) {
|
|
58
|
+
const client = await this.openConnection(options);
|
|
59
|
+
try {
|
|
60
|
+
const lock = await client.getMailboxLock("INBOX");
|
|
61
|
+
try {
|
|
62
|
+
const mailbox = client.mailbox;
|
|
63
|
+
if (!mailbox) return {};
|
|
64
|
+
return {
|
|
65
|
+
uidValidity: typeof mailbox.uidValidity === "bigint" ? Number(mailbox.uidValidity) : mailbox.uidValidity,
|
|
66
|
+
uidNext: typeof mailbox.uidNext === "bigint" ? Number(mailbox.uidNext) : mailbox.uidNext,
|
|
67
|
+
exists: mailbox.exists
|
|
68
|
+
};
|
|
69
|
+
} finally {
|
|
70
|
+
lock.release();
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
await client.logout().catch(() => void 0);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async fetchUidRange(options, range, opts = {}) {
|
|
77
|
+
const client = await this.openConnection(options);
|
|
78
|
+
const out = [];
|
|
79
|
+
try {
|
|
80
|
+
const lock = await client.getMailboxLock("INBOX");
|
|
81
|
+
try {
|
|
82
|
+
const iterator = client.fetch(
|
|
83
|
+
range,
|
|
84
|
+
{ uid: true, source: true, internalDate: true, flags: true },
|
|
85
|
+
{ uid: true }
|
|
86
|
+
);
|
|
87
|
+
for await (const message of iterator) {
|
|
88
|
+
if (!message.source) continue;
|
|
89
|
+
out.push({
|
|
90
|
+
uid: Number(message.uid),
|
|
91
|
+
rawBody: Buffer.isBuffer(message.source) ? message.source : Buffer.from(message.source),
|
|
92
|
+
internalDate: message.internalDate ? new Date(message.internalDate) : void 0,
|
|
93
|
+
flags: message.flags ? Array.from(message.flags) : void 0
|
|
94
|
+
});
|
|
95
|
+
if (opts.limit && out.length >= opts.limit) break;
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
lock.release();
|
|
99
|
+
}
|
|
100
|
+
} finally {
|
|
101
|
+
await client.logout().catch(() => void 0);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
async searchUidsByFromAndSince(options, criteria) {
|
|
106
|
+
const client = await this.openConnection(options);
|
|
107
|
+
try {
|
|
108
|
+
const lock = await client.getMailboxLock("INBOX");
|
|
109
|
+
try {
|
|
110
|
+
const query = {};
|
|
111
|
+
const addresses = (criteria.fromAddresses ?? []).map((s) => typeof s === "string" ? s.trim() : "").filter((s) => s.length > 0);
|
|
112
|
+
if (addresses.length === 1) {
|
|
113
|
+
query.from = addresses[0];
|
|
114
|
+
} else if (addresses.length > 1) {
|
|
115
|
+
let acc = { from: addresses[addresses.length - 1] };
|
|
116
|
+
for (let i = addresses.length - 2; i >= 0; i--) {
|
|
117
|
+
acc = { or: [{ from: addresses[i] }, acc] };
|
|
118
|
+
}
|
|
119
|
+
Object.assign(query, acc);
|
|
120
|
+
}
|
|
121
|
+
if (criteria.sinceDate instanceof Date && !Number.isNaN(criteria.sinceDate.getTime())) {
|
|
122
|
+
query.since = criteria.sinceDate;
|
|
123
|
+
}
|
|
124
|
+
if (Object.keys(query).length === 0) return [];
|
|
125
|
+
const searchFn = client.search;
|
|
126
|
+
if (typeof searchFn !== "function") return [];
|
|
127
|
+
const raw = await searchFn.call(client, query, { uid: true });
|
|
128
|
+
if (raw === false || !Array.isArray(raw)) return [];
|
|
129
|
+
return raw.map((u) => typeof u === "bigint" ? Number(u) : u).filter((u) => Number.isFinite(u));
|
|
130
|
+
} finally {
|
|
131
|
+
lock.release();
|
|
132
|
+
}
|
|
133
|
+
} finally {
|
|
134
|
+
await client.logout().catch(() => void 0);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async appendSent(options, rawMessage) {
|
|
138
|
+
const client = await this.openConnection(options);
|
|
139
|
+
try {
|
|
140
|
+
const sentMailbox = await resolveSentMailbox(client);
|
|
141
|
+
await client.append(sentMailbox, rawMessage, ["\\Seen"]);
|
|
142
|
+
} finally {
|
|
143
|
+
await client.logout().catch(() => void 0);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function pickSentMailbox(mailboxes) {
|
|
148
|
+
if (Array.isArray(mailboxes)) {
|
|
149
|
+
const sent = mailboxes.find(
|
|
150
|
+
(mailbox) => mailbox?.specialUse === "\\Sent" && typeof mailbox.path === "string" && mailbox.path.length > 0
|
|
151
|
+
);
|
|
152
|
+
if (sent?.path) return sent.path;
|
|
153
|
+
}
|
|
154
|
+
return "Sent";
|
|
155
|
+
}
|
|
156
|
+
async function resolveSentMailbox(client) {
|
|
157
|
+
try {
|
|
158
|
+
const mailboxes = typeof client.list === "function" ? await client.list() : void 0;
|
|
159
|
+
return pickSentMailbox(mailboxes);
|
|
160
|
+
} catch {
|
|
161
|
+
return "Sent";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function extractCapabilityKeys(client) {
|
|
165
|
+
const fromServerInfo = client.serverInfo?.capability;
|
|
166
|
+
if (fromServerInfo) {
|
|
167
|
+
return Array.from(fromServerInfo).map((value) => String(value).toUpperCase());
|
|
168
|
+
}
|
|
169
|
+
const caps = client.capabilities;
|
|
170
|
+
if (!caps) return [];
|
|
171
|
+
if (caps instanceof Map) {
|
|
172
|
+
return Array.from(caps.keys()).map((value) => String(value).toUpperCase());
|
|
173
|
+
}
|
|
174
|
+
return Array.from(caps).map((value) => String(value).toUpperCase());
|
|
175
|
+
}
|
|
176
|
+
async function loadImapFlow() {
|
|
177
|
+
const mod = await import("imapflow");
|
|
178
|
+
return { ImapFlow: mod.ImapFlow };
|
|
179
|
+
}
|
|
180
|
+
let cachedClient = null;
|
|
181
|
+
function getImapClient() {
|
|
182
|
+
if (!cachedClient) cachedClient = new ImapflowClient();
|
|
183
|
+
return cachedClient;
|
|
184
|
+
}
|
|
185
|
+
function setImapClient(client) {
|
|
186
|
+
cachedClient = client;
|
|
187
|
+
}
|
|
188
|
+
function credentialsToConnection(credentials) {
|
|
189
|
+
assertTransportAllowed(credentials);
|
|
190
|
+
const timeoutMs = resolveSocketTimeoutMs();
|
|
191
|
+
return {
|
|
192
|
+
host: credentials.imapHost,
|
|
193
|
+
port: Number(credentials.imapPort),
|
|
194
|
+
user: credentials.imapUser,
|
|
195
|
+
pass: credentials.imapPassword,
|
|
196
|
+
transport: credentials.imapTls,
|
|
197
|
+
...timeoutMs !== void 0 ? { timeoutMs } : {}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function resolveSocketTimeoutMs() {
|
|
201
|
+
const raw = Number.parseInt(process.env.OM_CHANNEL_IMAP_SOCKET_TIMEOUT_MS ?? "", 10);
|
|
202
|
+
return Number.isFinite(raw) && raw > 0 ? raw : void 0;
|
|
203
|
+
}
|
|
204
|
+
export {
|
|
205
|
+
credentialsToConnection,
|
|
206
|
+
getImapClient,
|
|
207
|
+
pickSentMailbox,
|
|
208
|
+
setImapClient
|
|
209
|
+
};
|
|
210
|
+
//# sourceMappingURL=imap-client.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_imap/lib/imap-client.ts"],
|
|
4
|
+
"sourcesContent": ["import type { ImapCredentials } from './credentials'\nimport { resolveSafeHostAddress } from './host-pinning'\nimport { assertTransportAllowed } from './transport'\n\n/**\n * Thin wrapper around `imapflow` so the adapter and tests can stay agnostic of\n * the SDK shape. We only expose the operations the adapter actually performs:\n * - `connectAndValidate` \u2014 open + LOGIN + LIST capabilities (used by `validateCredentials`)\n * - `selectInbox` \u2014 open the INBOX mailbox and read UIDVALIDITY / UIDNEXT\n * - `fetchUidRange` \u2014 fetch RFC822 bodies for a UID range (used by polling worker)\n * - `appendSent` \u2014 append a sent message to the Sent folder if available\n *\n * The wrapper avoids leaking `imapflow` types to callers so we can swap to\n * `node-imap` or a mock without touching adapter code.\n */\n\nexport type ImapTransport = 'tls' | 'starttls' | 'none'\n\nexport interface ImapConnectionOptions {\n host: string\n port: number\n user: string\n pass: string\n transport: ImapTransport\n /** Connection + greeting timeout (ms). Default 60000 (Spec B). */\n timeoutMs?: number\n /**\n * TCP+TLS connect + greeting timeout (ms). Default 15000. The health probe\n * passes a tighter value so it fails fast within the hub's 10s budget.\n */\n connectTimeoutMs?: number\n}\n\nexport interface ImapFolderState {\n uidValidity?: number\n uidNext?: number\n exists?: number\n}\n\nexport interface ImapFetchedMessage {\n uid: number\n rawBody: Buffer\n /** Server-reported INTERNALDATE \u2014 fallback when MIME date headers are missing. */\n internalDate?: Date\n /** Server flags (`\\Seen`, `\\Answered`, \u2026). */\n flags?: string[]\n}\n\nexport interface ImapClient {\n connectAndValidate(options: ImapConnectionOptions): Promise<{ capabilities: string[] }>\n selectInbox(\n options: ImapConnectionOptions,\n ): Promise<ImapFolderState>\n fetchUidRange(\n options: ImapConnectionOptions,\n range: string,\n opts?: { limit?: number },\n ): Promise<ImapFetchedMessage[]>\n /**\n * Run an IMAP `SEARCH` (with `UID` flag) and return matching UIDs.\n * Supports `OR FROM` chaining (server-side sender filter) and `SINCE` date\n * narrowing. Used by the inbound poll path to avoid pulling the entire\n * mailbox when the hub only cares about messages from known CRM contacts.\n *\n * `fromAddresses` is OR'd: `OR FROM \"a@x.com\" OR FROM \"b@y.com\" FROM \"c@z.com\"`.\n * `sinceDate` is formatted as IMAP date (`DD-Mon-YYYY`) for the SINCE clause.\n * Returns UIDs in mailbox order (typically ascending). Empty array = no match.\n */\n searchUidsByFromAndSince(\n options: ImapConnectionOptions,\n criteria: { fromAddresses?: string[]; sinceDate?: Date },\n ): Promise<number[]>\n appendSent(\n options: ImapConnectionOptions,\n rawMessage: Buffer,\n ): Promise<void>\n}\n\n/**\n * Default IMAP client backed by `imapflow`. Imported lazily so test environments\n * that don't install `imapflow` (the unit tests use a hand-rolled mock) keep working.\n */\nclass ImapflowClient implements ImapClient {\n private async openConnection(options: ImapConnectionOptions): Promise<ImapflowConnection> {\n const { ImapFlow } = await loadImapFlow()\n // Resolve + pin the host to a validated public IP at connect time, so a\n // hostname that (re)resolves to an internal address can't be abused for\n // SSRF. We dial the IP but keep the hostname as the TLS servername so SNI +\n // certificate hostname verification still target the real host.\n const pinned = await resolveSafeHostAddress(options.host)\n const client = new ImapFlow({\n host: pinned.host,\n port: options.port,\n secure: options.transport === 'tls',\n auth: { user: options.user, pass: options.pass },\n // Enforce certificate verification on every encrypted transport (implicit\n // TLS and STARTTLS). Only cleartext ('none', gated behind an env opt-in)\n // omits the TLS options. Mirrors the SMTP client so an upstream default\n // change can't silently disable cert checks.\n tls:\n options.transport === 'none'\n ? undefined\n : { rejectUnauthorized: true, ...(pinned.servername ? { servername: pinned.servername } : {}) },\n logger: false,\n // Gmail's IMAP can take 15-30s to respond to NAMESPACE under load even\n // after a successful AUTHENTICATE \u2014 observed during demo with valid\n // credentials and clean TLS. A 10s socket timeout aborts the command\n // mid-stream and surfaces as \"NoConnection\"/\"Unexpected close\" to the\n // worker, which then marks the channel as 'error'. 60s is enough for\n // any reasonable IMAP server while still bailing on truly dead hosts.\n socketTimeout: options.timeoutMs ?? 60_000,\n // Initial TCP+TLS handshake is usually fast; cap at 15s so a non-responsive\n // host bails before the UI flow stalls. Greeting can be slow on some\n // providers (Gmail occasionally takes 5-10s), so allow 15s there too.\n connectionTimeout: options.connectTimeoutMs ?? 15_000,\n greetingTimeout: options.connectTimeoutMs ?? 15_000,\n } as Record<string, unknown>)\n // Attach a defensive 'error' listener so tcp-level errors emitted on the\n // EventEmitter (e.g. socket reset during an idle lock) don't crash the\n // Node process via `unhandledError`. The error still bubbles up through\n // the awaited operation; the listener exists purely to satisfy Node's\n // EventEmitter contract.\n const eventClient = client as unknown as { on?: (event: string, listener: (err: unknown) => void) => void }\n if (typeof eventClient.on === 'function') {\n eventClient.on('error', () => {\n // Swallow \u2014 surfaced to the caller via the awaited promise.\n })\n }\n await client.connect()\n if (options.transport === 'starttls') {\n // Verify STARTTLS actually upgraded the connection. ImapFlow exposes the\n // negotiated state via `secureConnection` (true after STARTTLS) \u2014 refuse\n // to proceed if the server didn't advertise it.\n const secured = (client as unknown as { secureConnection?: boolean }).secureConnection === true\n if (!secured) {\n await client.logout().catch(() => undefined)\n throw new Error(\n 'IMAP server did not advertise STARTTLS \u2014 cannot authenticate over cleartext. Switch transport to tls (port 993) or contact the mailbox provider.',\n )\n }\n }\n return client\n }\n\n async connectAndValidate(options: ImapConnectionOptions): Promise<{ capabilities: string[] }> {\n const client = await this.openConnection(options)\n try {\n // imapflow exposes capabilities as a `Map<string, boolean | string>` \u2014\n // iterating yields `[key, value]` tuples which break `.map(String)`.\n // Read the keys explicitly so consumers get the capability names.\n const capabilityKeys = extractCapabilityKeys(client)\n return { capabilities: capabilityKeys }\n } finally {\n await client.logout().catch(() => undefined)\n }\n }\n\n async selectInbox(options: ImapConnectionOptions): Promise<ImapFolderState> {\n const client = await this.openConnection(options)\n try {\n const lock = await client.getMailboxLock('INBOX')\n try {\n const mailbox = client.mailbox as { uidValidity?: number | bigint; uidNext?: number | bigint; exists?: number } | null\n if (!mailbox) return {}\n return {\n uidValidity: typeof mailbox.uidValidity === 'bigint' ? Number(mailbox.uidValidity) : mailbox.uidValidity,\n uidNext: typeof mailbox.uidNext === 'bigint' ? Number(mailbox.uidNext) : mailbox.uidNext,\n exists: mailbox.exists,\n }\n } finally {\n lock.release()\n }\n } finally {\n await client.logout().catch(() => undefined)\n }\n }\n\n async fetchUidRange(\n options: ImapConnectionOptions,\n range: string,\n opts: { limit?: number } = {},\n ): Promise<ImapFetchedMessage[]> {\n const client = await this.openConnection(options)\n const out: ImapFetchedMessage[] = []\n try {\n const lock = await client.getMailboxLock('INBOX')\n try {\n // `{ uid: true }` as the THIRD arg (FetchOptions) makes imapflow treat\n // `range` as a UID range. Without it the range is read as message-sequence\n // numbers, and a sequence range like \"200:*\" collapses to the single newest\n // message (\"*\") \u2014 so each poll would fetch only the latest mail and silently\n // skip every other message that arrived in the same gap. The `uid: true` in\n // the SECOND arg (FetchQueryObject) is unrelated: it only asks to include the\n // UID field in each response row.\n const iterator = client.fetch(\n range,\n { uid: true, source: true, internalDate: true, flags: true },\n { uid: true },\n )\n for await (const message of iterator) {\n if (!message.source) continue\n out.push({\n uid: Number(message.uid),\n rawBody: Buffer.isBuffer(message.source) ? message.source : Buffer.from(message.source),\n internalDate: message.internalDate ? new Date(message.internalDate) : undefined,\n flags: message.flags ? Array.from(message.flags as Iterable<string>) : undefined,\n })\n if (opts.limit && out.length >= opts.limit) break\n }\n } finally {\n lock.release()\n }\n } finally {\n await client.logout().catch(() => undefined)\n }\n return out\n }\n\n async searchUidsByFromAndSince(\n options: ImapConnectionOptions,\n criteria: { fromAddresses?: string[]; sinceDate?: Date },\n ): Promise<number[]> {\n const client = await this.openConnection(options)\n try {\n const lock = await client.getMailboxLock('INBOX')\n try {\n // imapflow's search() takes a SearchQuery object. We construct one that\n // mirrors `SEARCH (OR FROM ... FROM ...) SINCE DD-Mon-YYYY` using its\n // documented shapes:\n // - `from` accepts a single string; for multiple we use `or: [{from}, {from}]`\n // (recursive \u2014 imapflow flattens to `OR (FROM a) (FROM b)` IMAP syntax).\n // - `since` accepts a Date and imapflow formats as `SINCE DD-Mon-YYYY`.\n const query: Record<string, unknown> = {}\n const addresses = (criteria.fromAddresses ?? [])\n .map((s) => (typeof s === 'string' ? s.trim() : ''))\n .filter((s) => s.length > 0)\n if (addresses.length === 1) {\n query.from = addresses[0]\n } else if (addresses.length > 1) {\n // Build nested OR: imapflow's `or` field expects an array of SearchQuery\n // objects. With 2 entries: `OR (FROM a) (FROM b)`. With N > 2 entries\n // we chain right-associatively: `OR (FROM a) (OR (FROM b) (FROM c) ...)`.\n let acc: Record<string, unknown> = { from: addresses[addresses.length - 1] }\n for (let i = addresses.length - 2; i >= 0; i--) {\n acc = { or: [{ from: addresses[i] }, acc] }\n }\n Object.assign(query, acc)\n }\n if (criteria.sinceDate instanceof Date && !Number.isNaN(criteria.sinceDate.getTime())) {\n query.since = criteria.sinceDate\n }\n if (Object.keys(query).length === 0) return []\n\n const searchFn = (client as unknown as {\n search?: (q: Record<string, unknown>, opts?: { uid?: boolean }) => Promise<Array<number | bigint> | false>\n }).search\n if (typeof searchFn !== 'function') return []\n const raw = await searchFn.call(client, query, { uid: true })\n if (raw === false || !Array.isArray(raw)) return []\n return raw.map((u) => (typeof u === 'bigint' ? Number(u) : u)).filter((u) => Number.isFinite(u))\n } finally {\n lock.release()\n }\n } finally {\n await client.logout().catch(() => undefined)\n }\n }\n\n async appendSent(options: ImapConnectionOptions, rawMessage: Buffer): Promise<void> {\n const client = await this.openConnection(options)\n try {\n const sentMailbox = await resolveSentMailbox(client)\n await client.append(sentMailbox, rawMessage, ['\\\\Seen'])\n } finally {\n await client.logout().catch(() => undefined)\n }\n }\n}\n\n/**\n * Pick the server's Sent folder from a `LIST` response. Providers expose it\n * under different paths ('[Gmail]/Sent Mail', localized names, \u2026) so we match\n * the RFC 6154 SPECIAL-USE `\\Sent` attribute rather than assuming 'Sent'.\n */\nexport function pickSentMailbox(\n mailboxes: Array<{ path?: string; specialUse?: string }> | null | undefined,\n): string {\n if (Array.isArray(mailboxes)) {\n const sent = mailboxes.find(\n (mailbox) =>\n mailbox?.specialUse === '\\\\Sent' && typeof mailbox.path === 'string' && mailbox.path.length > 0,\n )\n if (sent?.path) return sent.path\n }\n return 'Sent'\n}\n\nasync function resolveSentMailbox(client: ImapflowConnection): Promise<string> {\n // Discover the real Sent folder via SPECIAL-USE; fall back to the conventional\n // 'Sent' when listing is unsupported or no \\Sent mailbox is advertised.\n try {\n const mailboxes = typeof client.list === 'function' ? await client.list() : undefined\n return pickSentMailbox(mailboxes)\n } catch {\n // LIST failed (server quirk / transient) \u2014 fall back to the conventional folder.\n return 'Sent'\n }\n}\n\nfunction extractCapabilityKeys(client: ImapflowConnection): string[] {\n // imapflow's `client.capabilities` is a `Map<string, boolean | string>`\n // (see imapflow/lib/imap-flow.js \u2014 `this.capabilities = new Map()`). The\n // legacy `serverInfo?.capability` (set by the ID response) may be an\n // iterable of strings; prefer it when present, otherwise read the Map keys.\n const fromServerInfo = client.serverInfo?.capability\n if (fromServerInfo) {\n return Array.from(fromServerInfo).map((value) => String(value).toUpperCase())\n }\n const caps = client.capabilities\n if (!caps) return []\n if (caps instanceof Map) {\n return Array.from(caps.keys()).map((value) => String(value).toUpperCase())\n }\n // Fallback for non-Map iterables (test mocks).\n return Array.from(caps as Iterable<string>).map((value) => String(value).toUpperCase())\n}\n\ninterface ImapflowConnection {\n serverInfo?: { capability?: Iterable<string> }\n capabilities?: Iterable<string> | Map<string, unknown>\n mailbox: unknown\n connect(): Promise<void>\n logout(): Promise<void>\n getMailboxLock(name: string): Promise<{ release(): void }>\n fetch(range: string, query: Record<string, unknown>, options?: Record<string, unknown>): AsyncIterable<{ uid: number; source?: Buffer | string; internalDate?: Date | string; flags?: Iterable<string> }>\n append(mailbox: string, rawMessage: Buffer, flags?: string[]): Promise<void>\n list?(): Promise<Array<{ path?: string; specialUse?: string }>>\n}\n\nasync function loadImapFlow(): Promise<{ ImapFlow: new (options: Record<string, unknown>) => ImapflowConnection }> {\n // Dynamic import so unit tests that mock the client don't require `imapflow` installed.\n const mod = (await import('imapflow')) as unknown as { ImapFlow: new (options: Record<string, unknown>) => ImapflowConnection }\n return { ImapFlow: mod.ImapFlow }\n}\n\nlet cachedClient: ImapClient | null = null\n\nexport function getImapClient(): ImapClient {\n if (!cachedClient) cachedClient = new ImapflowClient()\n return cachedClient\n}\n\n/**\n * Test-only hook to swap the default IMAP client with a mock implementation.\n * Production code never calls this.\n */\nexport function setImapClient(client: ImapClient | null): void {\n cachedClient = client\n}\n\nexport function credentialsToConnection(credentials: ImapCredentials): ImapConnectionOptions {\n assertTransportAllowed(credentials)\n const timeoutMs = resolveSocketTimeoutMs()\n return {\n host: credentials.imapHost,\n port: Number(credentials.imapPort),\n user: credentials.imapUser,\n pass: credentials.imapPassword,\n transport: credentials.imapTls,\n ...(timeoutMs !== undefined ? { timeoutMs } : {}),\n }\n}\n\n/**\n * Operator override for the IMAP socket timeout. Defaults (when unset/invalid)\n * to `undefined` so the client falls back to its 60s default; the previous 10s\n * flaked under real-world IMAP latency. Spec \u00A7 Configuration documents this knob.\n */\nfunction resolveSocketTimeoutMs(): number | undefined {\n const raw = Number.parseInt(process.env.OM_CHANNEL_IMAP_SOCKET_TIMEOUT_MS ?? '', 10)\n return Number.isFinite(raw) && raw > 0 ? raw : undefined\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeMimeInbound
|
|
3
|
+
} from "@open-mercato/core/modules/communication_channels/lib/email-mime";
|
|
4
|
+
async function normalizeInboundImapMessage(options) {
|
|
5
|
+
const mailparser = await import("mailparser");
|
|
6
|
+
const parsed = await mailparser.simpleParser(options.rawMessage);
|
|
7
|
+
return normalizeMimeInbound({
|
|
8
|
+
parsed,
|
|
9
|
+
accountIdentifier: options.accountIdentifier,
|
|
10
|
+
fallbackMessageId: `imap:${options.uid ?? "unknown"}@${options.accountIdentifier}`,
|
|
11
|
+
resolveConversationId: ({ messageId, references }) => references[0] ?? messageId,
|
|
12
|
+
fallbackDate: options.fallbackDate,
|
|
13
|
+
channelMetadata: () => ({ uid: options.uid })
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export {
|
|
17
|
+
normalizeInboundImapMessage
|
|
18
|
+
};
|
|
19
|
+
//# sourceMappingURL=normalize-inbound.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_imap/lib/normalize-inbound.ts"],
|
|
4
|
+
"sourcesContent": ["import type { NormalizedInboundMessage } from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport {\n normalizeMimeInbound,\n type ParsedMail,\n} from '@open-mercato/core/modules/communication_channels/lib/email-mime'\n\n/**\n * Convert a raw RFC2822 MIME buffer (delivered by IMAP fetch) to the hub's\n * canonical `NormalizedInboundMessage`. Parses with `mailparser`, then delegates\n * threading / attachments / headers to the shared `normalizeMimeInbound` helper.\n *\n * Threading:\n * - `externalMessageId` := MIME `Message-ID` header (RFC2822). Required by\n * IMAP/SMTP; if missing we fall back to `imap:<uid>@<account>` so downstream\n * idempotency still has a deterministic key.\n * - `replyToExternalId` := `In-Reply-To` header (single value).\n * - `externalConversationId` := the root of the References chain when present,\n * otherwise the message id itself (single-message thread).\n */\n\nexport interface NormalizeInboundOptions {\n rawMessage: Buffer\n /** UID from the IMAP fetch \u2014 embedded into `channelMetadata.uid` for diagnostics. */\n uid?: number\n /** External identifier of the receiving channel (typically the account's email). */\n accountIdentifier: string\n /** Fallback timestamp if the parsed message has no Date header. */\n fallbackDate?: Date\n}\n\nexport async function normalizeInboundImapMessage(\n options: NormalizeInboundOptions,\n): Promise<NormalizedInboundMessage> {\n const mailparser = (await import('mailparser')) as unknown as {\n simpleParser: (buf: Buffer | string) => Promise<ParsedMail>\n }\n const parsed = await mailparser.simpleParser(options.rawMessage)\n\n return normalizeMimeInbound({\n parsed,\n accountIdentifier: options.accountIdentifier,\n fallbackMessageId: `imap:${options.uid ?? 'unknown'}@${options.accountIdentifier}`,\n resolveConversationId: ({ messageId, references }) => references[0] ?? messageId,\n fallbackDate: options.fallbackDate,\n channelMetadata: () => ({ uid: options.uid }),\n })\n}\n"],
|
|
5
|
+
"mappings": "AACA;AAAA,EACE;AAAA,OAEK;AA0BP,eAAsB,4BACpB,SACmC;AACnC,QAAM,aAAc,MAAM,OAAO,YAAY;AAG7C,QAAM,SAAS,MAAM,WAAW,aAAa,QAAQ,UAAU;AAE/D,SAAO,qBAAqB;AAAA,IAC1B;AAAA,IACA,mBAAmB,QAAQ;AAAA,IAC3B,mBAAmB,QAAQ,QAAQ,OAAO,SAAS,IAAI,QAAQ,iBAAiB;AAAA,IAChF,uBAAuB,CAAC,EAAE,WAAW,WAAW,MAAM,WAAW,CAAC,KAAK;AAAA,IACvE,cAAc,QAAQ;AAAA,IACtB,iBAAiB,OAAO,EAAE,KAAK,QAAQ,IAAI;AAAA,EAC7C,CAAC;AACH;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { resolveSafeHostAddress } from "./host-pinning.js";
|
|
2
|
+
import { assertTransportAllowed } from "./transport.js";
|
|
3
|
+
class NodemailerClient {
|
|
4
|
+
async verify(options) {
|
|
5
|
+
const { transporter } = await this.createTransporter(options);
|
|
6
|
+
try {
|
|
7
|
+
await transporter.verify();
|
|
8
|
+
} finally {
|
|
9
|
+
transporter.close();
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
async send(options, message) {
|
|
13
|
+
const { transporter, MailComposer } = await this.createTransporter(options);
|
|
14
|
+
try {
|
|
15
|
+
const mailOptions = {
|
|
16
|
+
from: message.from,
|
|
17
|
+
to: message.to,
|
|
18
|
+
cc: message.cc,
|
|
19
|
+
bcc: message.bcc,
|
|
20
|
+
subject: message.subject,
|
|
21
|
+
text: message.text,
|
|
22
|
+
html: message.html,
|
|
23
|
+
messageId: message.messageId,
|
|
24
|
+
inReplyTo: message.inReplyTo,
|
|
25
|
+
references: message.references,
|
|
26
|
+
attachments: message.attachments?.map((a) => ({
|
|
27
|
+
filename: a.filename,
|
|
28
|
+
content: a.content,
|
|
29
|
+
contentType: a.contentType,
|
|
30
|
+
cid: a.cid,
|
|
31
|
+
contentDisposition: a.inline ? "inline" : "attachment"
|
|
32
|
+
})),
|
|
33
|
+
headers: message.headers
|
|
34
|
+
};
|
|
35
|
+
let raw = Buffer.alloc(0);
|
|
36
|
+
let composedMessageId = message.messageId;
|
|
37
|
+
if (typeof MailComposer === "function") {
|
|
38
|
+
try {
|
|
39
|
+
const composed = new MailComposer(mailOptions);
|
|
40
|
+
const compiled = composed.compile();
|
|
41
|
+
raw = await new Promise((resolve, reject) => {
|
|
42
|
+
compiled.build((err, output) => {
|
|
43
|
+
if (err) reject(err);
|
|
44
|
+
else resolve(output);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
const messageIdFn = compiled.messageId;
|
|
48
|
+
if (typeof messageIdFn === "function") {
|
|
49
|
+
composedMessageId = messageIdFn.call(compiled) ?? composedMessageId;
|
|
50
|
+
}
|
|
51
|
+
} catch (composeError) {
|
|
52
|
+
raw = Buffer.alloc(0);
|
|
53
|
+
console.warn(
|
|
54
|
+
"[internal] channel_imap: failed to build RFC2822 bytes for Sent-folder append:",
|
|
55
|
+
composeError instanceof Error ? composeError.message : composeError
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const info = await transporter.sendMail(mailOptions);
|
|
60
|
+
const id = info.messageId ?? composedMessageId ?? info.envelope?.messageId;
|
|
61
|
+
if (!id) throw new Error("[internal] SMTP server did not return a Message-ID");
|
|
62
|
+
return { messageId: id, raw, response: info.response };
|
|
63
|
+
} finally {
|
|
64
|
+
transporter.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async createTransporter(options) {
|
|
68
|
+
const mod = await import("nodemailer");
|
|
69
|
+
const createTransport = mod.createTransport ?? mod.default?.createTransport;
|
|
70
|
+
if (typeof createTransport !== "function") {
|
|
71
|
+
throw new Error("nodemailer.createTransport is unavailable");
|
|
72
|
+
}
|
|
73
|
+
const MailComposer = mod.MailComposer ?? mod.default?.MailComposer;
|
|
74
|
+
const pinned = await resolveSafeHostAddress(options.host);
|
|
75
|
+
const transporter = createTransport({
|
|
76
|
+
host: pinned.host,
|
|
77
|
+
port: options.port,
|
|
78
|
+
secure: options.transport === "tls",
|
|
79
|
+
requireTLS: options.transport === "starttls",
|
|
80
|
+
auth: { user: options.user, pass: options.pass },
|
|
81
|
+
connectionTimeout: options.timeoutMs ?? 1e4,
|
|
82
|
+
// Reject downgrade attacks: only allow cleartext when the operator
|
|
83
|
+
// explicitly opts into `transport: 'none'`. Even then, refuse to skip
|
|
84
|
+
// certificate verification on STARTTLS / TLS.
|
|
85
|
+
tls: options.transport === "none" ? void 0 : { rejectUnauthorized: true, ...pinned.servername ? { servername: pinned.servername } : {} }
|
|
86
|
+
});
|
|
87
|
+
return { transporter, MailComposer };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let cachedClient = null;
|
|
91
|
+
function getSmtpClient() {
|
|
92
|
+
if (!cachedClient) cachedClient = new NodemailerClient();
|
|
93
|
+
return cachedClient;
|
|
94
|
+
}
|
|
95
|
+
function setSmtpClient(client) {
|
|
96
|
+
cachedClient = client;
|
|
97
|
+
}
|
|
98
|
+
function credentialsToSmtpConnection(credentials) {
|
|
99
|
+
assertTransportAllowed(credentials);
|
|
100
|
+
return {
|
|
101
|
+
host: credentials.smtpHost,
|
|
102
|
+
port: Number(credentials.smtpPort),
|
|
103
|
+
user: credentials.smtpUser,
|
|
104
|
+
pass: credentials.smtpPassword,
|
|
105
|
+
transport: credentials.smtpTls
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export {
|
|
109
|
+
credentialsToSmtpConnection,
|
|
110
|
+
getSmtpClient,
|
|
111
|
+
setSmtpClient
|
|
112
|
+
};
|
|
113
|
+
//# sourceMappingURL=smtp-client.js.map
|