@open-mercato/channel-imap 0.9.1-develop.7477.1.64ba865101 → 0.9.1-develop.7497.1.543ea44c40

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,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { resolveSafeHostAddress } from "./host-pinning.js";
2
3
  import { assertTransportAllowed } from "./transport.js";
3
4
  import { createLogger } from "@open-mercato/shared/lib/logger";
@@ -12,6 +13,10 @@ async function loadMailComposer() {
12
13
  return void 0;
13
14
  }
14
15
  }
16
+ function generateMessageId(from) {
17
+ const domain = /@([^\s<>@]+)>?\s*$/.exec(from)?.[1] ?? "localhost";
18
+ return `<${randomUUID()}@${domain}>`;
19
+ }
15
20
  class NodemailerClient {
16
21
  async verify(options) {
17
22
  const { transporter } = await this.createTransporter(options);
@@ -32,7 +37,7 @@ class NodemailerClient {
32
37
  subject: message.subject,
33
38
  text: message.text,
34
39
  html: message.html,
35
- messageId: message.messageId,
40
+ messageId: message.messageId ?? generateMessageId(message.from),
36
41
  inReplyTo: message.inReplyTo,
37
42
  references: message.references,
38
43
  attachments: message.attachments?.map((a) => ({
@@ -116,6 +121,7 @@ function credentialsToSmtpConnection(credentials) {
116
121
  }
117
122
  export {
118
123
  credentialsToSmtpConnection,
124
+ generateMessageId,
119
125
  getSmtpClient,
120
126
  setSmtpClient
121
127
  };
@@ -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\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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",
4
+ "sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport 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 the client generates one from the sender domain. */\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\n// send() composes the message twice (MailComposer for the Sent-folder bytes, then\n// transporter.sendMail), and each composition would generate its own Message-ID.\n// Pinning one up front keeps the Sent copy, the delivered message and the\n// returned id identical so replies thread with the Sent copy.\nexport function generateMessageId(from: string): string {\n const domain = /@([^\\s<>@]+)>?\\s*$/.exec(from)?.[1] ?? 'localhost'\n return `<${randomUUID()}@${domain}>`\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 ?? generateMessageId(message.from),\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": "AAAA,SAAS,kBAAkB;AAE3B,SAAS,8BAA8B;AACvC,SAAS,8BAA8B;AACvC,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,cAAc;AA4D1C,eAAe,mBAA0D;AACvE,MAAI;AACF,UAAM,MAAO,MAAM,OAAO,8BAA8B;AAGxD,UAAM,OAAO,IAAI,WAAW;AAC5B,WAAO,OAAO,SAAS,aAAa,OAAO;AAAA,EAC7C,SAAS,KAAK;AACZ,WAAO,KAAK,0CAA0C,EAAE,IAAI,CAAC;AAC7D,WAAO;AAAA,EACT;AACF;AAMO,SAAS,kBAAkB,MAAsB;AACtD,QAAM,SAAS,qBAAqB,KAAK,IAAI,IAAI,CAAC,KAAK;AACvD,SAAO,IAAI,WAAW,CAAC,IAAI,MAAM;AACnC;AAEA,MAAM,iBAAuC;AAAA,EAC3C,MAAM,OAAO,SAA+C;AAC1D,UAAM,EAAE,YAAY,IAAI,MAAM,KAAK,kBAAkB,OAAO;AAC5D,QAAI;AACF,YAAM,YAAY,OAAO;AAAA,IAC3B,UAAE;AAGA,kBAAY,MAAM;AAAA,IACpB;AAAA,EACF;AAAA,EAEA,MAAM,KAAK,SAAgC,SAA+C;AACxF,UAAM,EAAE,aAAa,aAAa,IAAI,MAAM,KAAK,kBAAkB,OAAO;AAC1E,QAAI;AACF,YAAM,cAAuC;AAAA,QAC3C,MAAM,QAAQ;AAAA,QACd,IAAI,QAAQ;AAAA,QACZ,IAAI,QAAQ;AAAA,QACZ,KAAK,QAAQ;AAAA,QACb,SAAS,QAAQ;AAAA,QACjB,MAAM,QAAQ;AAAA,QACd,MAAM,QAAQ;AAAA,QACd,WAAW,QAAQ,aAAa,kBAAkB,QAAQ,IAAI;AAAA,QAC9D,WAAW,QAAQ;AAAA,QACnB,YAAY,QAAQ;AAAA,QACpB,aAAa,QAAQ,aAAa,IAAI,CAAC,OAAO;AAAA,UAC5C,UAAU,EAAE;AAAA,UACZ,SAAS,EAAE;AAAA,UACX,aAAa,EAAE;AAAA,UACf,KAAK,EAAE;AAAA,UACP,oBAAoB,EAAE,SAAS,WAAW;AAAA,QAC5C,EAAE;AAAA,QACF,SAAS,QAAQ;AAAA,MACnB;AAQA,UAAI,MAAc,OAAO,MAAM,CAAC;AAChC,UAAI,oBAAoB,QAAQ;AAChC,UAAI,OAAO,iBAAiB,YAAY;AACtC,YAAI;AACF,gBAAM,WAAW,IAAI,aAAa,WAAW;AAM7C,gBAAM,WAAW,SAAS,QAAQ;AAClC,gBAAM,MAAM,IAAI,QAAgB,CAAC,SAAS,WAAW;AACnD,qBAAS,MAAM,CAAC,KAAK,WAAW;AAC9B,kBAAI,IAAK,QAAO,GAAG;AAAA,kBACd,SAAQ,MAAM;AAAA,YACrB,CAAC;AAAA,UACH,CAAC;AACD,gBAAM,cAAc,SAAS;AAC7B,cAAI,OAAO,gBAAgB,YAAY;AACrC,gCAAoB,YAAY,KAAK,QAAQ,KAAK;AAAA,UACpD;AAAA,QACF,SAAS,cAAc;AAIrB,gBAAM,OAAO,MAAM,CAAC;AACpB,iBAAO,KAAK,wDAAwD,EAAE,KAAK,aAAa,CAAC;AAAA,QAC3F;AAAA,MACF;AAEA,YAAM,OAAQ,MAAM,YAAY,SAAS,WAAW;AAKpD,YAAM,KAAK,KAAK,aAAa,qBAAqB,KAAK,UAAU;AACjE,UAAI,CAAC,GAAI,OAAM,IAAI,MAAM,oDAAoD;AAC7E,aAAO,EAAE,WAAW,IAAI,KAAK,UAAU,KAAK,SAAS;AAAA,IACvD,UAAE;AACA,kBAAY,MAAM;AAAA,IACpB;AAAA,EACF;AAAA,EAEA,MAAc,kBACZ,SAIC;AACD,UAAM,MAAO,MAAM,OAAO,YAAY;AAQtC,UAAM,kBAAkB,IAAI,mBAAmB,IAAI,SAAS;AAC5D,QAAI,OAAO,oBAAoB,YAAY;AACzC,YAAM,IAAI,MAAM,2CAA2C;AAAA,IAC7D;AACA,UAAM,eAAe,IAAI,gBAAgB,IAAI,SAAS,gBAAgB,MAAM,iBAAiB;AAI7F,UAAM,SAAS,MAAM,uBAAuB,QAAQ,IAAI;AACxD,UAAM,cAAc,gBAAgB;AAAA,MAClC,MAAM,OAAO;AAAA,MACb,MAAM,QAAQ;AAAA,MACd,QAAQ,QAAQ,cAAc;AAAA,MAC9B,YAAY,QAAQ,cAAc;AAAA,MAClC,MAAM,EAAE,MAAM,QAAQ,MAAM,MAAM,QAAQ,KAAK;AAAA,MAC/C,mBAAmB,QAAQ,aAAa;AAAA;AAAA;AAAA;AAAA,MAIxC,KACE,QAAQ,cAAc,SAClB,SACA,EAAE,oBAAoB,MAAM,GAAI,OAAO,aAAa,EAAE,YAAY,OAAO,WAAW,IAAI,CAAC,EAAG;AAAA,IACpG,CAAC;AACD,WAAO,EAAE,aAAa,aAAa;AAAA,EACrC;AACF;AAQA,IAAI,eAAkC;AAE/B,SAAS,gBAA4B;AAC1C,MAAI,CAAC,aAAc,gBAAe,IAAI,iBAAiB;AACvD,SAAO;AACT;AAEO,SAAS,cAAc,QAAiC;AAC7D,iBAAe;AACjB;AAEO,SAAS,4BAA4B,aAAqD;AAC/F,yBAAuB,WAAW;AAClC,SAAO;AAAA,IACL,MAAM,YAAY;AAAA,IAClB,MAAM,OAAO,YAAY,QAAQ;AAAA,IACjC,MAAM,YAAY;AAAA,IAClB,MAAM,YAAY;AAAA,IAClB,WAAW,YAAY;AAAA,EACzB;AACF;",
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.9.1-develop.7477.1.64ba865101",
3
+ "version": "0.9.1-develop.7497.1.543ea44c40",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -62,9 +62,9 @@
62
62
  }
63
63
  },
64
64
  "dependencies": {
65
- "@open-mercato/core": "0.9.1-develop.7477.1.64ba865101",
66
- "@open-mercato/queue": "0.9.1-develop.7477.1.64ba865101",
67
- "@open-mercato/ui": "0.9.1-develop.7477.1.64ba865101",
65
+ "@open-mercato/core": "0.9.1-develop.7497.1.543ea44c40",
66
+ "@open-mercato/queue": "0.9.1-develop.7497.1.543ea44c40",
67
+ "@open-mercato/ui": "0.9.1-develop.7497.1.543ea44c40",
68
68
  "@types/mailparser": "^3.4.5",
69
69
  "@types/nodemailer": "^8.0.1",
70
70
  "imapflow": "^1.7.6",
@@ -73,12 +73,12 @@
73
73
  },
74
74
  "peerDependencies": {
75
75
  "@mikro-orm/postgresql": "^7.0.14",
76
- "@open-mercato/shared": "0.9.1-develop.7477.1.64ba865101",
76
+ "@open-mercato/shared": "0.9.1-develop.7497.1.543ea44c40",
77
77
  "react": "^19.0.0",
78
78
  "react-dom": "^19.0.0"
79
79
  },
80
80
  "devDependencies": {
81
- "@open-mercato/shared": "0.9.1-develop.7477.1.64ba865101",
81
+ "@open-mercato/shared": "0.9.1-develop.7497.1.543ea44c40",
82
82
  "@types/jest": "^30.0.0",
83
83
  "@types/react": "^19.2.18",
84
84
  "@types/react-dom": "^19.2.5",
@@ -1,6 +1,9 @@
1
- import { getSmtpClient, setSmtpClient, type SmtpConnectionOptions } from '../smtp-client'
1
+ import { generateMessageId, getSmtpClient, setSmtpClient, type SmtpConnectionOptions } from '../smtp-client'
2
2
 
3
- const sendMail = jest.fn(async () => ({ messageId: '<server@example.com>', response: '250 OK' }))
3
+ const sendMail = jest.fn(async (_options: { messageId?: string }) => ({
4
+ messageId: '<server@example.com>' as string | undefined,
5
+ response: '250 OK',
6
+ }))
4
7
  const close = jest.fn()
5
8
 
6
9
  jest.mock('nodemailer', () => ({
@@ -59,4 +62,40 @@ describe('NodemailerClient.send — RFC2822 capture for the Sent-folder append',
59
62
  expect(result.raw.toString('utf8')).toContain('<caller@example.com>')
60
63
  expect(result.messageId).toBe('<server@example.com>')
61
64
  })
65
+
66
+ // Regression (#7101): without a caller id, MailComposer and sendMail each generated
67
+ // their own Message-ID, so the Sent-folder copy did not match the delivered message.
68
+ it('uses one generated Message-ID for the Sent copy and the delivered message', async () => {
69
+ sendMail.mockImplementationOnce(async (options: { messageId?: string }) => ({
70
+ messageId: options.messageId,
71
+ response: '250 OK',
72
+ }))
73
+
74
+ const result = await getSmtpClient().send(connection, {
75
+ from: 'Support Team <support@mail.example.com>',
76
+ to: ['recipient@example.com'],
77
+ subject: 'No caller id',
78
+ text: 'body',
79
+ })
80
+
81
+ const sentOptions = sendMail.mock.calls[0][0]
82
+ expect(sentOptions.messageId).toMatch(/^<[0-9a-f-]{36}@mail\.example\.com>$/)
83
+ expect(result.raw.toString('utf8')).toContain(`Message-ID: ${sentOptions.messageId}`)
84
+ expect(result.messageId).toBe(sentOptions.messageId)
85
+ })
86
+ })
87
+
88
+ describe('generateMessageId', () => {
89
+ it('derives the domain from a bare or display-name sender address', () => {
90
+ expect(generateMessageId('a@example.org')).toMatch(/^<[0-9a-f-]{36}@example\.org>$/)
91
+ expect(generateMessageId('"Ops" <ops@example.net>')).toMatch(/^<[0-9a-f-]{36}@example\.net>$/)
92
+ })
93
+
94
+ it('falls back to localhost when the sender has no domain', () => {
95
+ expect(generateMessageId('nobody')).toMatch(/^<[0-9a-f-]{36}@localhost>$/)
96
+ })
97
+
98
+ it('generates a distinct id per call', () => {
99
+ expect(generateMessageId('a@example.org')).not.toBe(generateMessageId('a@example.org'))
100
+ })
62
101
  })
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import type { ImapCredentials } from './credentials'
2
3
  import { resolveSafeHostAddress } from './host-pinning'
3
4
  import { assertTransportAllowed } from './transport'
@@ -28,7 +29,7 @@ export interface SmtpMessage {
28
29
  subject?: string
29
30
  text?: string
30
31
  html?: string
31
- /** RFC2822 Message-ID; if omitted nodemailer generates one. */
32
+ /** RFC2822 Message-ID; if omitted the client generates one from the sender domain. */
32
33
  messageId?: string
33
34
  /** RFC2822 In-Reply-To (single value). */
34
35
  inReplyTo?: string
@@ -76,6 +77,15 @@ async function loadMailComposer(): Promise<MailComposerCtor | undefined> {
76
77
  }
77
78
  }
78
79
 
80
+ // send() composes the message twice (MailComposer for the Sent-folder bytes, then
81
+ // transporter.sendMail), and each composition would generate its own Message-ID.
82
+ // Pinning one up front keeps the Sent copy, the delivered message and the
83
+ // returned id identical so replies thread with the Sent copy.
84
+ export function generateMessageId(from: string): string {
85
+ const domain = /@([^\s<>@]+)>?\s*$/.exec(from)?.[1] ?? 'localhost'
86
+ return `<${randomUUID()}@${domain}>`
87
+ }
88
+
79
89
  class NodemailerClient implements SmtpClient {
80
90
  async verify(options: SmtpConnectionOptions): Promise<void> {
81
91
  const { transporter } = await this.createTransporter(options)
@@ -99,7 +109,7 @@ class NodemailerClient implements SmtpClient {
99
109
  subject: message.subject,
100
110
  text: message.text,
101
111
  html: message.html,
102
- messageId: message.messageId,
112
+ messageId: message.messageId ?? generateMessageId(message.from),
103
113
  inReplyTo: message.inReplyTo,
104
114
  references: message.references,
105
115
  attachments: message.attachments?.map((a) => ({