@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
|
|
5
|
-
"mappings": "
|
|
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": "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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.9.1-develop.
|
|
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.
|
|
66
|
-
"@open-mercato/queue": "0.9.1-develop.
|
|
67
|
-
"@open-mercato/ui": "0.9.1-develop.
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
|
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) => ({
|