@open-mercato/channel-imap 0.8.1-develop.7296.1.2111d779db → 0.9.0

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.
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
3
+ import { isPrivateIPv6 } from "@open-mercato/shared/lib/network";
3
4
  const FORBIDDEN_HOST_NAMES = /* @__PURE__ */ new Set([
4
5
  "localhost",
5
6
  "localhost6",
@@ -61,7 +62,10 @@ function isInternalHost(rawHost) {
61
62
  const host = normalizeHost(rawHost);
62
63
  if (!host) return false;
63
64
  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 (host.includes(":")) {
66
+ const address = host.replace(/%.*$/, "");
67
+ return PRIVATE_IPV6_PATTERNS.some((pattern) => pattern.test(address)) || isPrivateIPv6(address);
68
+ }
65
69
  if (isObfuscatedIpv4(host)) return true;
66
70
  return isDottedDecimalQuad(host) && PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(host));
67
71
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
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": "AAAA,SAAS,SAAS;AAClB,SAAS,+BAA+B;AAkBxC,MAAM,uBAAuB,oBAAI,IAAI;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,MAAM,wBAAkC;AAAA,EACtC;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AACF;AAEA,MAAM,wBAAkC;AAAA,EACtC;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AACF;AAEA,SAAS,oBAAoB,MAAuB;AAClD,QAAM,QAAQ,KAAK,MAAM,GAAG;AAC5B,SAAO,MAAM,WAAW,KAAK,MAAM,MAAM,CAAC,SAAS,YAAY,KAAK,IAAI,KAAK,OAAO,IAAI,KAAK,GAAG;AAClG;AASA,SAAS,iBAAiB,MAAuB;AAC/C,MAAI,KAAK,SAAS,GAAG,EAAG,QAAO;AAC/B,MAAI,QAAQ,KAAK,IAAI,EAAG,QAAO;AAC/B,MAAI,oBAAoB,KAAK,IAAI,EAAG,QAAO;AAC3C,QAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,MAAI,CAAC,OAAO,MAAM,CAAC,UAAU,cAAc,KAAK,KAAK,CAAC,EAAG,QAAO;AAChE,MAAI,oBAAoB,IAAI,KAAK,CAAC,OAAO,KAAK,CAAC,UAAU,MAAM,SAAS,KAAK,MAAM,WAAW,GAAG,CAAC,EAAG,QAAO;AAC5G,SAAO;AACT;AAEA,SAAS,cAAc,KAAqB;AAC1C,MAAI,OAAO,IAAI,KAAK,EAAE,YAAY;AAClC,MAAI,KAAK,WAAW,GAAG,KAAK,KAAK,SAAS,GAAG,EAAG,QAAO,KAAK,MAAM,GAAG,EAAE;AACvE,QAAM,aAAa,KAAK,MAAM,+CAA+C;AAC7E,MAAI,WAAY,QAAO,WAAW,CAAC;AACnC,SAAO;AACT;AAMO,SAAS,eAAe,SAA0B;AACvD,QAAM,OAAO,cAAc,OAAO;AAClC,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI,qBAAqB,IAAI,IAAI,KAAK,KAAK,SAAS,YAAY,EAAG,QAAO;AAC1E,MAAI,KAAK,SAAS,GAAG,EAAG,QAAO,sBAAsB,KAAK,CAAC,YAAY,QAAQ,KAAK,IAAI,CAAC;AACzF,MAAI,iBAAiB,IAAI,EAAG,QAAO;AAMnC,SAAO,oBAAoB,IAAI,KAAK,sBAAsB,KAAK,CAAC,YAAY,QAAQ,KAAK,IAAI,CAAC;AAChG;AAEA,SAAS,eAAe,MAAc,KAA+E;AACnH,MAAI,wBAAwB,QAAQ,IAAI,sCAAsC,KAAK,EAAG;AACtF,MAAI,CAAC,KAAK,KAAK,EAAG;AAClB,MAAI,eAAe,IAAI,GAAG;AACxB,QAAI,SAAS;AAAA,MACX,MAAM;AAAA,MACN,SACE;AAAA,IACJ,CAAC;AAAA,EACH;AACF;AAEA,SAAS,eAAe,OAAwB;AAC9C,SAAO,EACJ,OAAO,EACP,IAAI,GAAG,GAAG,KAAK,gBAAgB,EAC/B,IAAI,KAAK,GAAG,KAAK,gBAAgB,EACjC,YAAY,CAAC,OAAO,QAAQ,eAAe,OAAO,GAAG,CAAC;AAC3D;AAWO,MAAM,wBAAwB,EAClC,OAAO;AAAA,EACN,UAAU,eAAe,MAAM;AAAA,EAC/B,UAAU,EAAE,OACT,OAAO,EACP,IAAI,EACJ,IAAI,GAAG,sCAAsC,EAC7C,IAAI,OAAO,4BAA4B;AAAA,EAC1C,SAAS,EAAE,KAAK,CAAC,OAAO,YAAY,MAAM,CAAC;AAAA,EAC3C,UAAU,EAAE,OAAO,EAAE,IAAI,GAAG,wBAAwB;AAAA,EACpD,cAAc,EAAE,OAAO,EAAE,IAAI,GAAG,wBAAwB;AAAA,EAExD,UAAU,eAAe,MAAM;AAAA,EAC/B,UAAU,EAAE,OACT,OAAO,EACP,IAAI,EACJ,IAAI,GAAG,sCAAsC,EAC7C,IAAI,OAAO,4BAA4B;AAAA,EAC1C,SAAS,EAAE,KAAK,CAAC,OAAO,YAAY,MAAM,CAAC;AAAA,EAC3C,UAAU,EAAE,OAAO,EAAE,IAAI,GAAG,wBAAwB;AAAA,EACpD,cAAc,EAAE,OAAO,EAAE,IAAI,GAAG,wBAAwB;AAAA,EAExD,aAAa,EAAE,OAAO,EAAE,MAAM,oCAAoC;AACpE,CAAC,EAKA,YAAY;AAWR,MAAM,yBAAyB,EACnC,OAAO;AAAA,EACN,aAAa,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,EAAE,SAAS;AAAA,EACxD,SAAS,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,EAAE,OAAO,CAAC,CAAC,EAAE,SAAS;AAAA,EACpD,YAAY,EAAE,OAAO,EAAE,SAAS;AAClC,CAAC,EACA,QAAQ,EACR,YAAY;",
4
+ "sourcesContent": ["import { z } from 'zod'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { isPrivateIPv6 } from '@open-mercato/shared/lib/network'\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 * NAT64 / 6to4 / IPv4-compatible IPv6 embedding an internal IPv4,\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(':')) {\n const address = host.replace(/%.*$/, '')\n return PRIVATE_IPV6_PATTERNS.some((pattern) => pattern.test(address)) || isPrivateIPv6(address)\n }\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
6
  "names": []
7
7
  }
@@ -2,6 +2,16 @@ import { resolveSafeHostAddress } from "./host-pinning.js";
2
2
  import { assertTransportAllowed } from "./transport.js";
3
3
  import { createLogger } from "@open-mercato/shared/lib/logger";
4
4
  const logger = createLogger("channel_imap");
5
+ async function loadMailComposer() {
6
+ try {
7
+ const mod = await import("nodemailer/lib/mail-composer");
8
+ const ctor = mod.default ?? mod;
9
+ return typeof ctor === "function" ? ctor : void 0;
10
+ } catch (err) {
11
+ logger.warn("failed to load nodemailer MailComposer", { err });
12
+ return void 0;
13
+ }
14
+ }
5
15
  class NodemailerClient {
6
16
  async verify(options) {
7
17
  const { transporter } = await this.createTransporter(options);
@@ -69,7 +79,7 @@ class NodemailerClient {
69
79
  if (typeof createTransport !== "function") {
70
80
  throw new Error("nodemailer.createTransport is unavailable");
71
81
  }
72
- const MailComposer = mod.MailComposer ?? mod.default?.MailComposer;
82
+ const MailComposer = mod.MailComposer ?? mod.default?.MailComposer ?? await loadMailComposer();
73
83
  const pinned = await resolveSafeHostAddress(options.host);
74
84
  const transporter = createTransport({
75
85
  host: pinned.host,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/channel_imap/lib/smtp-client.ts"],
4
- "sourcesContent": ["import type { ImapCredentials } from './credentials'\nimport { resolveSafeHostAddress } from './host-pinning'\nimport { assertTransportAllowed } from './transport'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('channel_imap')\n\n/**\n * Outbound SMTP client wrapper. Same trade-offs as `imap-client.ts`: we wrap\n * `nodemailer` behind a tiny interface so tests can swap in a mock and the\n * adapter doesn't import SDK types directly.\n */\n\nexport interface SmtpConnectionOptions {\n host: string\n port: number\n user: string\n pass: string\n transport: 'tls' | 'starttls' | 'none'\n timeoutMs?: number\n}\n\nexport interface SmtpMessage {\n from: string\n to: string[]\n cc?: string[]\n bcc?: string[]\n subject?: string\n text?: string\n html?: string\n /** RFC2822 Message-ID; if omitted nodemailer generates one. */\n messageId?: string\n /** RFC2822 In-Reply-To (single value). */\n inReplyTo?: string\n /** RFC2822 References (whitespace-delimited list). */\n references?: string[]\n attachments?: Array<{\n filename: string\n content: Buffer\n contentType?: string\n cid?: string\n inline?: boolean\n }>\n headers?: Record<string, string>\n}\n\nexport interface SmtpSendResult {\n /** Effective Message-ID. */\n messageId: string\n /** Raw RFC2822 message buffer (used for Sent-folder append). */\n raw: Buffer\n /** Provider response string. */\n response?: string\n}\n\nexport interface SmtpClient {\n verify(options: SmtpConnectionOptions): Promise<void>\n send(options: SmtpConnectionOptions, message: SmtpMessage): Promise<SmtpSendResult>\n}\n\nclass NodemailerClient implements SmtpClient {\n async verify(options: SmtpConnectionOptions): Promise<void> {\n const { transporter } = await this.createTransporter(options)\n try {\n await transporter.verify()\n } finally {\n // Mirror send(): close on every path so a failed verify (wrong password,\n // unreachable host \u2014 the common case) does not leak the socket pool.\n transporter.close()\n }\n }\n\n async send(options: SmtpConnectionOptions, message: SmtpMessage): Promise<SmtpSendResult> {\n const { transporter, MailComposer } = await this.createTransporter(options)\n try {\n const mailOptions: Record<string, unknown> = {\n from: message.from,\n to: message.to,\n cc: message.cc,\n bcc: message.bcc,\n subject: message.subject,\n text: message.text,\n html: message.html,\n messageId: message.messageId,\n inReplyTo: message.inReplyTo,\n references: message.references,\n attachments: message.attachments?.map((a) => ({\n filename: a.filename,\n content: a.content,\n contentType: a.contentType,\n cid: a.cid,\n contentDisposition: a.inline ? 'inline' : 'attachment',\n })),\n headers: message.headers,\n }\n\n // Build the RFC2822 bytes ourselves via MailComposer so we can capture\n // them for the Sent-folder append (review H1, 2026-05-26).\n // nodemailer's `transporter.sendMail` info object does NOT contain `raw`\n // unless you configure a streamTransport, so naively reading\n // `info.raw` produces a 0-byte buffer and the Sent-folder append uploads\n // a corrupt message.\n let raw: Buffer = Buffer.alloc(0)\n let composedMessageId = message.messageId\n if (typeof MailComposer === 'function') {\n try {\n const composed = new MailComposer(mailOptions) as unknown as {\n compile: () => {\n build: (callback: (err: Error | null, output: Buffer) => void) => void\n messageId?: () => string | undefined\n }\n }\n const compiled = composed.compile()\n raw = await new Promise<Buffer>((resolve, reject) => {\n compiled.build((err, output) => {\n if (err) reject(err)\n else resolve(output)\n })\n })\n const messageIdFn = compiled.messageId\n if (typeof messageIdFn === 'function') {\n composedMessageId = messageIdFn.call(compiled) ?? composedMessageId\n }\n } catch (composeError) {\n // MailComposer build failed: the send below still delivers the mail, but we\n // cannot capture the RFC2822 bytes, so the caller skips the Sent-folder append.\n // Log so operators can diagnose missing Sent archival.\n raw = Buffer.alloc(0)\n logger.warn('failed to build RFC2822 bytes for Sent-folder append', { err: composeError })\n }\n }\n\n const info = (await transporter.sendMail(mailOptions)) as {\n messageId?: string\n envelope?: { messageId?: string }\n response?: string\n }\n const id = info.messageId ?? composedMessageId ?? info.envelope?.messageId\n if (!id) throw new Error('[internal] SMTP server did not return a Message-ID')\n return { messageId: id, raw, response: info.response }\n } finally {\n transporter.close()\n }\n }\n\n private async createTransporter(\n options: SmtpConnectionOptions,\n ): Promise<{\n transporter: NodemailerTransporter\n MailComposer: (new (mail: Record<string, unknown>) => unknown) | undefined\n }> {\n const mod = (await import('nodemailer')) as unknown as {\n default?: {\n createTransport: (opts: Record<string, unknown>) => NodemailerTransporter\n MailComposer?: new (mail: Record<string, unknown>) => unknown\n }\n createTransport?: (opts: Record<string, unknown>) => NodemailerTransporter\n MailComposer?: new (mail: Record<string, unknown>) => unknown\n }\n const createTransport = mod.createTransport ?? mod.default?.createTransport\n if (typeof createTransport !== 'function') {\n throw new Error('nodemailer.createTransport is unavailable')\n }\n const MailComposer = mod.MailComposer ?? mod.default?.MailComposer\n // Resolve + pin the SMTP host to a validated public IP at connect time\n // (DNS-rebinding-safe), keeping the hostname as the TLS servername for SNI +\n // certificate hostname verification.\n const pinned = await resolveSafeHostAddress(options.host)\n const transporter = createTransport({\n host: pinned.host,\n port: options.port,\n secure: options.transport === 'tls',\n requireTLS: options.transport === 'starttls',\n auth: { user: options.user, pass: options.pass },\n connectionTimeout: options.timeoutMs ?? 10_000,\n // Reject downgrade attacks: only allow cleartext when the operator\n // explicitly opts into `transport: 'none'`. Even then, refuse to skip\n // certificate verification on STARTTLS / TLS.\n tls:\n options.transport === 'none'\n ? undefined\n : { rejectUnauthorized: true, ...(pinned.servername ? { servername: pinned.servername } : {}) },\n })\n return { transporter, MailComposer }\n }\n}\n\ninterface NodemailerTransporter {\n verify(): Promise<true>\n sendMail(options: Record<string, unknown>): Promise<unknown>\n close(): void\n}\n\nlet cachedClient: SmtpClient | null = null\n\nexport function getSmtpClient(): SmtpClient {\n if (!cachedClient) cachedClient = new NodemailerClient()\n return cachedClient\n}\n\nexport function setSmtpClient(client: SmtpClient | null): void {\n cachedClient = client\n}\n\nexport function credentialsToSmtpConnection(credentials: ImapCredentials): SmtpConnectionOptions {\n assertTransportAllowed(credentials)\n return {\n host: credentials.smtpHost,\n port: Number(credentials.smtpPort),\n user: credentials.smtpUser,\n pass: credentials.smtpPassword,\n transport: credentials.smtpTls,\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { ImapCredentials } from './credentials'\nimport { resolveSafeHostAddress } from './host-pinning'\nimport { assertTransportAllowed } from './transport'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('channel_imap')\n\n/**\n * Outbound SMTP client wrapper. Same trade-offs as `imap-client.ts`: we wrap\n * `nodemailer` behind a tiny interface so tests can swap in a mock and the\n * adapter doesn't import SDK types directly.\n */\n\nexport interface SmtpConnectionOptions {\n host: string\n port: number\n user: string\n pass: string\n transport: 'tls' | 'starttls' | 'none'\n timeoutMs?: number\n}\n\nexport interface SmtpMessage {\n from: string\n to: string[]\n cc?: string[]\n bcc?: string[]\n subject?: string\n text?: string\n html?: string\n /** RFC2822 Message-ID; if omitted nodemailer generates one. */\n messageId?: string\n /** RFC2822 In-Reply-To (single value). */\n inReplyTo?: string\n /** RFC2822 References (whitespace-delimited list). */\n references?: string[]\n attachments?: Array<{\n filename: string\n content: Buffer\n contentType?: string\n cid?: string\n inline?: boolean\n }>\n headers?: Record<string, string>\n}\n\nexport interface SmtpSendResult {\n /** Effective Message-ID. */\n messageId: string\n /** Raw RFC2822 message buffer (used for Sent-folder append). */\n raw: Buffer\n /** Provider response string. */\n response?: string\n}\n\nexport interface SmtpClient {\n verify(options: SmtpConnectionOptions): Promise<void>\n send(options: SmtpConnectionOptions, message: SmtpMessage): Promise<SmtpSendResult>\n}\n\ntype MailComposerCtor = new (mail: Record<string, unknown>) => unknown\n\n// nodemailer 10 dropped the root `MailComposer` re-export that v9 exposed; it is\n// still published under the `nodemailer/lib/mail-composer` subpath. Without this\n// fallback the Sent-folder append silently uploads a 0-byte message.\nasync function loadMailComposer(): Promise<MailComposerCtor | undefined> {\n try {\n const mod = (await import('nodemailer/lib/mail-composer')) as unknown as {\n default?: MailComposerCtor\n } & MailComposerCtor\n const ctor = mod.default ?? mod\n return typeof ctor === 'function' ? ctor : undefined\n } catch (err) {\n logger.warn('failed to load nodemailer MailComposer', { err })\n return undefined\n }\n}\n\nclass NodemailerClient implements SmtpClient {\n async verify(options: SmtpConnectionOptions): Promise<void> {\n const { transporter } = await this.createTransporter(options)\n try {\n await transporter.verify()\n } finally {\n // Mirror send(): close on every path so a failed verify (wrong password,\n // unreachable host \u2014 the common case) does not leak the socket pool.\n transporter.close()\n }\n }\n\n async send(options: SmtpConnectionOptions, message: SmtpMessage): Promise<SmtpSendResult> {\n const { transporter, MailComposer } = await this.createTransporter(options)\n try {\n const mailOptions: Record<string, unknown> = {\n from: message.from,\n to: message.to,\n cc: message.cc,\n bcc: message.bcc,\n subject: message.subject,\n text: message.text,\n html: message.html,\n messageId: message.messageId,\n inReplyTo: message.inReplyTo,\n references: message.references,\n attachments: message.attachments?.map((a) => ({\n filename: a.filename,\n content: a.content,\n contentType: a.contentType,\n cid: a.cid,\n contentDisposition: a.inline ? 'inline' : 'attachment',\n })),\n headers: message.headers,\n }\n\n // Build the RFC2822 bytes ourselves via MailComposer so we can capture\n // them for the Sent-folder append (review H1, 2026-05-26).\n // nodemailer's `transporter.sendMail` info object does NOT contain `raw`\n // unless you configure a streamTransport, so naively reading\n // `info.raw` produces a 0-byte buffer and the Sent-folder append uploads\n // a corrupt message.\n let raw: Buffer = Buffer.alloc(0)\n let composedMessageId = message.messageId\n if (typeof MailComposer === 'function') {\n try {\n const composed = new MailComposer(mailOptions) as unknown as {\n compile: () => {\n build: (callback: (err: Error | null, output: Buffer) => void) => void\n messageId?: () => string | undefined\n }\n }\n const compiled = composed.compile()\n raw = await new Promise<Buffer>((resolve, reject) => {\n compiled.build((err, output) => {\n if (err) reject(err)\n else resolve(output)\n })\n })\n const messageIdFn = compiled.messageId\n if (typeof messageIdFn === 'function') {\n composedMessageId = messageIdFn.call(compiled) ?? composedMessageId\n }\n } catch (composeError) {\n // MailComposer build failed: the send below still delivers the mail, but we\n // cannot capture the RFC2822 bytes, so the caller skips the Sent-folder append.\n // Log so operators can diagnose missing Sent archival.\n raw = Buffer.alloc(0)\n logger.warn('failed to build RFC2822 bytes for Sent-folder append', { err: composeError })\n }\n }\n\n const info = (await transporter.sendMail(mailOptions)) as {\n messageId?: string\n envelope?: { messageId?: string }\n response?: string\n }\n const id = info.messageId ?? composedMessageId ?? info.envelope?.messageId\n if (!id) throw new Error('[internal] SMTP server did not return a Message-ID')\n return { messageId: id, raw, response: info.response }\n } finally {\n transporter.close()\n }\n }\n\n private async createTransporter(\n options: SmtpConnectionOptions,\n ): Promise<{\n transporter: NodemailerTransporter\n MailComposer: (new (mail: Record<string, unknown>) => unknown) | undefined\n }> {\n const mod = (await import('nodemailer')) as unknown as {\n default?: {\n createTransport: (opts: Record<string, unknown>) => NodemailerTransporter\n MailComposer?: new (mail: Record<string, unknown>) => unknown\n }\n createTransport?: (opts: Record<string, unknown>) => NodemailerTransporter\n MailComposer?: new (mail: Record<string, unknown>) => unknown\n }\n const createTransport = mod.createTransport ?? mod.default?.createTransport\n if (typeof createTransport !== 'function') {\n throw new Error('nodemailer.createTransport is unavailable')\n }\n const MailComposer = mod.MailComposer ?? mod.default?.MailComposer ?? await loadMailComposer()\n // Resolve + pin the SMTP host to a validated public IP at connect time\n // (DNS-rebinding-safe), keeping the hostname as the TLS servername for SNI +\n // certificate hostname verification.\n const pinned = await resolveSafeHostAddress(options.host)\n const transporter = createTransport({\n host: pinned.host,\n port: options.port,\n secure: options.transport === 'tls',\n requireTLS: options.transport === 'starttls',\n auth: { user: options.user, pass: options.pass },\n connectionTimeout: options.timeoutMs ?? 10_000,\n // Reject downgrade attacks: only allow cleartext when the operator\n // explicitly opts into `transport: 'none'`. Even then, refuse to skip\n // certificate verification on STARTTLS / TLS.\n tls:\n options.transport === 'none'\n ? undefined\n : { rejectUnauthorized: true, ...(pinned.servername ? { servername: pinned.servername } : {}) },\n })\n return { transporter, MailComposer }\n }\n}\n\ninterface NodemailerTransporter {\n verify(): Promise<true>\n sendMail(options: Record<string, unknown>): Promise<unknown>\n close(): void\n}\n\nlet cachedClient: SmtpClient | null = null\n\nexport function getSmtpClient(): SmtpClient {\n if (!cachedClient) cachedClient = new NodemailerClient()\n return cachedClient\n}\n\nexport function setSmtpClient(client: SmtpClient | null): void {\n cachedClient = client\n}\n\nexport function credentialsToSmtpConnection(credentials: ImapCredentials): SmtpConnectionOptions {\n assertTransportAllowed(credentials)\n return {\n host: credentials.smtpHost,\n port: Number(credentials.smtpPort),\n user: credentials.smtpUser,\n pass: credentials.smtpPassword,\n transport: credentials.smtpTls,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/channel-imap",
3
- "version": "0.8.1-develop.7296.1.2111d779db",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -62,23 +62,23 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@open-mercato/core": "0.8.1-develop.7296.1.2111d779db",
66
- "@open-mercato/queue": "0.8.1-develop.7296.1.2111d779db",
67
- "@open-mercato/ui": "0.8.1-develop.7296.1.2111d779db",
65
+ "@open-mercato/core": "0.9.0",
66
+ "@open-mercato/queue": "0.9.0",
67
+ "@open-mercato/ui": "0.9.0",
68
68
  "@types/mailparser": "^3.4.5",
69
69
  "@types/nodemailer": "^8.0.1",
70
70
  "imapflow": "^1.7.6",
71
71
  "mailparser": "^3.9.17",
72
- "nodemailer": "^9.0.6"
72
+ "nodemailer": "^10.0.10"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "@mikro-orm/postgresql": "^7.0.14",
76
- "@open-mercato/shared": "0.8.1-develop.7296.1.2111d779db",
76
+ "@open-mercato/shared": "0.9.0",
77
77
  "react": "^19.0.0",
78
78
  "react-dom": "^19.0.0"
79
79
  },
80
80
  "devDependencies": {
81
- "@open-mercato/shared": "0.8.1-develop.7296.1.2111d779db",
81
+ "@open-mercato/shared": "0.9.0",
82
82
  "@types/jest": "^30.0.0",
83
83
  "@types/react": "^19.2.18",
84
84
  "@types/react-dom": "^19.2.5",
@@ -97,6 +97,5 @@
97
97
  "type": "git",
98
98
  "url": "https://github.com/open-mercato/open-mercato",
99
99
  "directory": "packages/channel-imap"
100
- },
101
- "stableVersion": "0.8.0"
100
+ }
102
101
  }
@@ -59,6 +59,12 @@ describe('imapCredentialsSchema', () => {
59
59
  expect(() => imapCredentialsSchema.parse({ ...valid, smtpHost: 'localhost' })).toThrow(/private or loopback/i)
60
60
  })
61
61
 
62
+ it('rejects IPv6-embedded internal IPv4 hosts (NAT64, 6to4, IPv4-compatible)', () => {
63
+ expect(() => imapCredentialsSchema.parse({ ...valid, imapHost: '64:ff9b::7f00:1' })).toThrow(/private or loopback/i)
64
+ expect(() => imapCredentialsSchema.parse({ ...valid, smtpHost: '2002:7f00:1::' })).toThrow(/private or loopback/i)
65
+ expect(() => imapCredentialsSchema.parse({ ...valid, imapHost: '::7f00:1' })).toThrow(/private or loopback/i)
66
+ })
67
+
62
68
  it('honors the OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS escape hatch', () => {
63
69
  const previous = process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS
64
70
  process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS = 'true'
@@ -103,6 +109,21 @@ describe('isInternalHost (SSRF guard)', () => {
103
109
  'fe80::1',
104
110
  '::ffff:127.0.0.1',
105
111
  '::ffff:169.254.169.254',
112
+ '64:ff9b::7f00:1',
113
+ '64:ff9b::127.0.0.1',
114
+ '[64:ff9b::a9fe:a9fe]',
115
+ '2002:7f00:1::',
116
+ '2002:a9fe:a9fe::1',
117
+ '[2002:a00:1::]',
118
+ '::7f00:1',
119
+ '::127.0.0.1',
120
+ '[::a9fe:a9fe]',
121
+ '64:ff9b::7f00:1%eth0',
122
+ '[64:ff9b::7f00:1%25lo0]',
123
+ '2002:7f00:1::%en0',
124
+ '::7f00:1%lo0',
125
+ '::1%lo0',
126
+ 'fe80::1%eth0',
106
127
  ]
107
128
 
108
129
  const allowed = [
@@ -113,6 +134,9 @@ describe('isInternalHost (SSRF guard)', () => {
113
134
  '1.1.1.1',
114
135
  '203.0.113.10',
115
136
  '2001:4860:4860::8888',
137
+ '2606:4700:4700::1111',
138
+ '64:ff9b::808:808',
139
+ '2002:808:808::1',
116
140
  ]
117
141
 
118
142
  it.each(blocked)('blocks internal/obfuscated host %s', (host) => {
@@ -51,6 +51,34 @@ describe('resolveSafeHostAddress — connect-time SSRF pinning', () => {
51
51
  await expect(resolveSafeHostAddress('169.254.169.254')).rejects.toThrow(/private or loopback/i)
52
52
  })
53
53
 
54
+ it.each(['64:ff9b::7f00:1', '2002:7f00:1::', '::7f00:1', '[64:ff9b::a9fe:a9fe]', '64:ff9b::7f00:1%eth0'])(
55
+ 'rejects literal IPv6-embedded internal IPv4 %s without resolving it',
56
+ async (host) => {
57
+ const lookup = fakeLookup([{ address: '93.184.216.34', family: 4 }])
58
+ await expect(resolveSafeHostAddress(host, { lookup })).rejects.toThrow(/private or loopback/i)
59
+ expect(lookup).not.toHaveBeenCalled()
60
+ },
61
+ )
62
+
63
+ it('rejects a hostname whose AAAA record embeds an internal IPv4 (NAT64)', async () => {
64
+ const lookup = fakeLookup([{ address: '64:ff9b::a9fe:a9fe', family: 6 }])
65
+ await expect(resolveSafeHostAddress('nat64.attacker.test', { lookup })).rejects.toThrow(
66
+ /private or loopback/i,
67
+ )
68
+ })
69
+
70
+ it('pins a hostname whose NAT64 AAAA record embeds a public IPv4', async () => {
71
+ const lookup = fakeLookup([{ address: '64:ff9b::5db8:d822', family: 6 }])
72
+ const result = await resolveSafeHostAddress('imap.example.com', { lookup })
73
+ expect(result).toEqual({ host: '64:ff9b::5db8:d822', servername: 'imap.example.com' })
74
+ })
75
+
76
+ it('returns a literal IPv6-embedded internal IPv4 verbatim when OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true', async () => {
77
+ process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS = 'true'
78
+ const result = await resolveSafeHostAddress('64:ff9b::7f00:1')
79
+ expect(result).toEqual({ host: '64:ff9b::7f00:1' })
80
+ })
81
+
54
82
  it('throws when the hostname does not resolve to any address', async () => {
55
83
  const lookup = fakeLookup([])
56
84
  await expect(resolveSafeHostAddress('nxdomain.attacker.test', { lookup })).rejects.toThrow(
@@ -0,0 +1,62 @@
1
+ import { getSmtpClient, setSmtpClient, type SmtpConnectionOptions } from '../smtp-client'
2
+
3
+ const sendMail = jest.fn(async () => ({ messageId: '<server@example.com>', response: '250 OK' }))
4
+ const close = jest.fn()
5
+
6
+ jest.mock('nodemailer', () => ({
7
+ __esModule: true,
8
+ createTransport: jest.fn(() => ({ sendMail, close, verify: jest.fn(async () => true) })),
9
+ }))
10
+
11
+ jest.mock('../host-pinning', () => ({
12
+ resolveSafeHostAddress: jest.fn(async (host: string) => ({ host: '93.184.216.34', servername: host })),
13
+ }))
14
+
15
+ jest.mock('../transport', () => ({ assertTransportAllowed: jest.fn() }))
16
+
17
+ const connection: SmtpConnectionOptions = {
18
+ host: 'smtp.example.com',
19
+ port: 587,
20
+ transport: 'starttls',
21
+ user: 'user@example.com',
22
+ pass: 'secret',
23
+ }
24
+
25
+ describe('NodemailerClient.send — RFC2822 capture for the Sent-folder append', () => {
26
+ afterEach(() => {
27
+ setSmtpClient(null)
28
+ jest.clearAllMocks()
29
+ })
30
+
31
+ // Regression (#6757): nodemailer 10 dropped the root `MailComposer` re-export.
32
+ // The resolver falls back to the `nodemailer/lib/mail-composer` subpath; without
33
+ // it `raw` silently stays empty and the Sent-folder append uploads 0 bytes.
34
+ it('returns non-empty RFC2822 bytes even though nodemailer exposes no root MailComposer', async () => {
35
+ const result = await getSmtpClient().send(connection, {
36
+ from: 'sender@example.com',
37
+ to: ['recipient@example.com'],
38
+ subject: 'Sent folder append',
39
+ text: 'body',
40
+ })
41
+
42
+ expect(result.raw.length).toBeGreaterThan(0)
43
+ const raw = result.raw.toString('utf8')
44
+ expect(raw).toContain('Subject: Sent folder append')
45
+ expect(raw).toContain('recipient@example.com')
46
+ expect(sendMail).toHaveBeenCalledTimes(1)
47
+ expect(close).toHaveBeenCalledTimes(1)
48
+ })
49
+
50
+ it('prefers the composed Message-ID when the caller supplies one', async () => {
51
+ const result = await getSmtpClient().send(connection, {
52
+ from: 'sender@example.com',
53
+ to: ['recipient@example.com'],
54
+ subject: 'With id',
55
+ text: 'body',
56
+ messageId: '<caller@example.com>',
57
+ })
58
+
59
+ expect(result.raw.toString('utf8')).toContain('<caller@example.com>')
60
+ expect(result.messageId).toBe('<server@example.com>')
61
+ })
62
+ })
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod'
2
2
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
3
+ import { isPrivateIPv6 } from '@open-mercato/shared/lib/network'
3
4
 
4
5
  /**
5
6
  * SSRF guard: reject hostnames that resolve to internal networks. Operators
@@ -10,6 +11,7 @@ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
10
11
  *
11
12
  * The check is string-based: it rejects literal internal IPs, `localhost`, and
12
13
  * the obfuscated encodings that exist to evade such filters (IPv4-mapped IPv6,
14
+ * NAT64 / 6to4 / IPv4-compatible IPv6 embedding an internal IPv4,
13
15
  * decimal/hex/octal/short-form IPv4, bracketed and expanded IPv6). It does NOT
14
16
  * by itself catch a public hostname that resolves — or is DNS-rebound — to a
15
17
  * private address; that gap is closed at connect time by `resolveSafeHostAddress`
@@ -82,7 +84,10 @@ export function isInternalHost(rawHost: string): boolean {
82
84
  const host = normalizeHost(rawHost)
83
85
  if (!host) return false
84
86
  if (FORBIDDEN_HOST_NAMES.has(host) || host.endsWith('.localhost')) return true
85
- if (host.includes(':')) return PRIVATE_IPV6_PATTERNS.some((pattern) => pattern.test(host))
87
+ if (host.includes(':')) {
88
+ const address = host.replace(/%.*$/, '')
89
+ return PRIVATE_IPV6_PATTERNS.some((pattern) => pattern.test(address)) || isPrivateIPv6(address)
90
+ }
86
91
  if (isObfuscatedIpv4(host)) return true
87
92
  // Only treat the private-range patterns as internal for a real dotted-decimal
88
93
  // quad. Otherwise a hostname whose first label merely looks like a private
@@ -58,6 +58,24 @@ export interface SmtpClient {
58
58
  send(options: SmtpConnectionOptions, message: SmtpMessage): Promise<SmtpSendResult>
59
59
  }
60
60
 
61
+ type MailComposerCtor = new (mail: Record<string, unknown>) => unknown
62
+
63
+ // nodemailer 10 dropped the root `MailComposer` re-export that v9 exposed; it is
64
+ // still published under the `nodemailer/lib/mail-composer` subpath. Without this
65
+ // fallback the Sent-folder append silently uploads a 0-byte message.
66
+ async function loadMailComposer(): Promise<MailComposerCtor | undefined> {
67
+ try {
68
+ const mod = (await import('nodemailer/lib/mail-composer')) as unknown as {
69
+ default?: MailComposerCtor
70
+ } & MailComposerCtor
71
+ const ctor = mod.default ?? mod
72
+ return typeof ctor === 'function' ? ctor : undefined
73
+ } catch (err) {
74
+ logger.warn('failed to load nodemailer MailComposer', { err })
75
+ return undefined
76
+ }
77
+ }
78
+
61
79
  class NodemailerClient implements SmtpClient {
62
80
  async verify(options: SmtpConnectionOptions): Promise<void> {
63
81
  const { transporter } = await this.createTransporter(options)
@@ -161,7 +179,7 @@ class NodemailerClient implements SmtpClient {
161
179
  if (typeof createTransport !== 'function') {
162
180
  throw new Error('nodemailer.createTransport is unavailable')
163
181
  }
164
- const MailComposer = mod.MailComposer ?? mod.default?.MailComposer
182
+ const MailComposer = mod.MailComposer ?? mod.default?.MailComposer ?? await loadMailComposer()
165
183
  // Resolve + pin the SMTP host to a validated public IP at connect time
166
184
  // (DNS-rebinding-safe), keeping the hostname as the TLS servername for SNI +
167
185
  // certificate hostname verification.