@open-mercato/channel-imap 0.8.1-develop.7296.1.2111d779db → 0.8.1-develop.7441.1.b42958b98c
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 +1 -1
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.js +16 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.js.map +2 -2
- package/dist/modules/channel_imap/lib/connect-form.js +66 -0
- package/dist/modules/channel_imap/lib/connect-form.js.map +7 -0
- package/dist/modules/channel_imap/lib/credentials.js +5 -1
- package/dist/modules/channel_imap/lib/credentials.js.map +2 -2
- package/dist/modules/channel_imap/lib/smtp-client.js +11 -1
- package/dist/modules/channel_imap/lib/smtp-client.js.map +2 -2
- package/dist/modules/channel_imap/widgets/injection/connect/widget.client.js +134 -290
- package/dist/modules/channel_imap/widgets/injection/connect/widget.client.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.ts +17 -0
- package/src/modules/channel_imap/lib/__tests__/connect-form.test.ts +84 -0
- package/src/modules/channel_imap/lib/__tests__/credentials.test.ts +24 -0
- package/src/modules/channel_imap/lib/__tests__/host-pinning.test.ts +28 -0
- package/src/modules/channel_imap/lib/__tests__/smtp-client.test.ts +62 -0
- package/src/modules/channel_imap/lib/connect-form.ts +75 -0
- package/src/modules/channel_imap/lib/credentials.ts +6 -1
- package/src/modules/channel_imap/lib/smtp-client.ts +19 -1
- package/src/modules/channel_imap/widgets/injection/connect/widget.client.tsx +135 -295
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:channel-imap] found
|
|
1
|
+
[build:channel-imap] found 34 entry points
|
|
2
2
|
[build:channel-imap] built successfully
|
|
@@ -58,5 +58,21 @@ test.describe("TC-CHANNEL-EMAIL-001: IMAP provider registration", () => {
|
|
|
58
58
|
expect(body?.fieldErrors ?? body?.error).toBeTruthy();
|
|
59
59
|
}
|
|
60
60
|
});
|
|
61
|
+
test("POST connect/credentials with an empty display name returns per-field details (#5593)", async ({ request }) => {
|
|
62
|
+
const token = await getAuthToken(request);
|
|
63
|
+
const response = await apiRequest(
|
|
64
|
+
request,
|
|
65
|
+
"POST",
|
|
66
|
+
"/api/communication_channels/channels/connect/credentials",
|
|
67
|
+
{
|
|
68
|
+
token,
|
|
69
|
+
data: { providerKey: "imap", displayName: "", credentials: {} }
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
expect(response.status()).toBe(422);
|
|
73
|
+
const body = await readJsonSafe(response);
|
|
74
|
+
expect(body?.error).toBe("Invalid request body");
|
|
75
|
+
expect(body?.details?.map((issue) => issue.path?.[0])).toEqual(["displayName"]);
|
|
76
|
+
});
|
|
61
77
|
});
|
|
62
78
|
//# sourceMappingURL=TC-CHANNEL-EMAIL-001.spec.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.ts"],
|
|
4
|
-
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { getAuthToken } from '@open-mercato/core/helpers/integration/authFixtures'\nimport { apiRequest } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\n\n/**\n * TC-CHANNEL-EMAIL-001 \u2014 IMAP provider visible to the hub's per-user channel API.\n *\n * After Slice 3e registers the `imap` adapter inside `@open-mercato/channel-imap`,\n * the per-user channel routes must accept `providerKey: 'imap'` as a known provider.\n * Without a live IMAP test server we exercise the routing-and-validation surface only;\n * actual credential validation against a real IMAP host is covered by the unit tests\n * in `lib/__tests__/validate-credentials.test.ts`.\n *\n * The route should NOT 404 the provider (proves registration), and should NOT 500\n * (proves the request reaches the adapter and rejects cleanly when no live server\n * is reachable).\n */\ntest.describe('TC-CHANNEL-EMAIL-001: IMAP provider registration', () => {\n test('POST connect/credentials with providerKey=imap reaches the adapter', async ({ request }) => {\n const token = await getAuthToken(request)\n const response = await apiRequest(\n request,\n 'POST',\n '/api/communication_channels/channels/connect/credentials',\n {\n token,\n data: {\n providerKey: 'imap',\n displayName: 'IMAP \u2014 integration test',\n credentials: {\n imapHost: 'invalid.test.example',\n imapPort: 993,\n imapTls: 'tls',\n imapUser: 'fake@example.test',\n imapPassword: 'wrong-password',\n smtpHost: 'invalid.test.example',\n smtpPort: 465,\n smtpTls: 'tls',\n smtpUser: 'fake@example.test',\n smtpPassword: 'wrong-password',\n fromAddress: 'fake@example.test',\n },\n },\n },\n )\n expect(response.status(), 'route should not 5xx').toBeLessThan(500)\n // 401 (no auth seeded) / 422 (validation failure surfaced via createCrudFormError) /\n // 502 ish \u2014 but never 404, which would indicate the provider isn't registered.\n expect(response.status(), 'IMAP provider should be registered').not.toBe(404)\n })\n\n test('POST connect/credentials with providerKey=imap and malformed credentials returns 422', async ({ request }) => {\n const token = await getAuthToken(request)\n const response = await apiRequest(\n request,\n 'POST',\n '/api/communication_channels/channels/connect/credentials',\n {\n token,\n data: {\n providerKey: 'imap',\n displayName: 'IMAP \u2014 malformed',\n credentials: {\n // Missing required imap/smtp fields entirely.\n fromAddress: 'not-an-email',\n },\n },\n },\n )\n expect(response.status()).toBeLessThan(500)\n expect([401, 422, 400]).toContain(response.status())\n if (response.status() === 422) {\n // The route surfaces credential-validation failures as { error, fieldErrors }\n // (see api/post/channels/connect/credentials/route.ts).\n const body = await readJsonSafe<{ error?: string; fieldErrors?: Record<string, string> }>(response)\n expect(body?.fieldErrors ?? body?.error).toBeTruthy()\n }\n })\n})\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB;AAC3B,SAAS,oBAAoB;AAe7B,KAAK,SAAS,oDAAoD,MAAM;AACtE,OAAK,sEAAsE,OAAO,EAAE,QAAQ,MAAM;AAChG,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,UAAM,WAAW,MAAM;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,QACE;AAAA,QACA,MAAM;AAAA,UACJ,aAAa;AAAA,UACb,aAAa;AAAA,UACb,aAAa;AAAA,YACX,UAAU;AAAA,YACV,UAAU;AAAA,YACV,SAAS;AAAA,YACT,UAAU;AAAA,YACV,cAAc;AAAA,YACd,UAAU;AAAA,YACV,UAAU;AAAA,YACV,SAAS;AAAA,YACT,UAAU;AAAA,YACV,cAAc;AAAA,YACd,aAAa;AAAA,UACf;AAAA,QACF;AAAA,MACF;AAAA,IACF;AACA,WAAO,SAAS,OAAO,GAAG,sBAAsB,EAAE,aAAa,GAAG;AAGlE,WAAO,SAAS,OAAO,GAAG,oCAAoC,EAAE,IAAI,KAAK,GAAG;AAAA,EAC9E,CAAC;AAED,OAAK,wFAAwF,OAAO,EAAE,QAAQ,MAAM;AAClH,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,UAAM,WAAW,MAAM;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,QACE;AAAA,QACA,MAAM;AAAA,UACJ,aAAa;AAAA,UACb,aAAa;AAAA,UACb,aAAa;AAAA;AAAA,YAEX,aAAa;AAAA,UACf;AAAA,QACF;AAAA,MACF;AAAA,IACF;AACA,WAAO,SAAS,OAAO,CAAC,EAAE,aAAa,GAAG;AAC1C,WAAO,CAAC,KAAK,KAAK,GAAG,CAAC,EAAE,UAAU,SAAS,OAAO,CAAC;AACnD,QAAI,SAAS,OAAO,MAAM,KAAK;AAG7B,YAAM,OAAO,MAAM,aAAuE,QAAQ;AAClG,aAAO,MAAM,eAAe,MAAM,KAAK,EAAE,WAAW;AAAA,IACtD;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
4
|
+
"sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { getAuthToken } from '@open-mercato/core/helpers/integration/authFixtures'\nimport { apiRequest } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\n\n/**\n * TC-CHANNEL-EMAIL-001 \u2014 IMAP provider visible to the hub's per-user channel API.\n *\n * After Slice 3e registers the `imap` adapter inside `@open-mercato/channel-imap`,\n * the per-user channel routes must accept `providerKey: 'imap'` as a known provider.\n * Without a live IMAP test server we exercise the routing-and-validation surface only;\n * actual credential validation against a real IMAP host is covered by the unit tests\n * in `lib/__tests__/validate-credentials.test.ts`.\n *\n * The route should NOT 404 the provider (proves registration), and should NOT 500\n * (proves the request reaches the adapter and rejects cleanly when no live server\n * is reachable).\n */\ntest.describe('TC-CHANNEL-EMAIL-001: IMAP provider registration', () => {\n test('POST connect/credentials with providerKey=imap reaches the adapter', async ({ request }) => {\n const token = await getAuthToken(request)\n const response = await apiRequest(\n request,\n 'POST',\n '/api/communication_channels/channels/connect/credentials',\n {\n token,\n data: {\n providerKey: 'imap',\n displayName: 'IMAP \u2014 integration test',\n credentials: {\n imapHost: 'invalid.test.example',\n imapPort: 993,\n imapTls: 'tls',\n imapUser: 'fake@example.test',\n imapPassword: 'wrong-password',\n smtpHost: 'invalid.test.example',\n smtpPort: 465,\n smtpTls: 'tls',\n smtpUser: 'fake@example.test',\n smtpPassword: 'wrong-password',\n fromAddress: 'fake@example.test',\n },\n },\n },\n )\n expect(response.status(), 'route should not 5xx').toBeLessThan(500)\n // 401 (no auth seeded) / 422 (validation failure surfaced via createCrudFormError) /\n // 502 ish \u2014 but never 404, which would indicate the provider isn't registered.\n expect(response.status(), 'IMAP provider should be registered').not.toBe(404)\n })\n\n test('POST connect/credentials with providerKey=imap and malformed credentials returns 422', async ({ request }) => {\n const token = await getAuthToken(request)\n const response = await apiRequest(\n request,\n 'POST',\n '/api/communication_channels/channels/connect/credentials',\n {\n token,\n data: {\n providerKey: 'imap',\n displayName: 'IMAP \u2014 malformed',\n credentials: {\n // Missing required imap/smtp fields entirely.\n fromAddress: 'not-an-email',\n },\n },\n },\n )\n expect(response.status()).toBeLessThan(500)\n expect([401, 422, 400]).toContain(response.status())\n if (response.status() === 422) {\n // The route surfaces credential-validation failures as { error, fieldErrors }\n // (see api/post/channels/connect/credentials/route.ts).\n const body = await readJsonSafe<{ error?: string; fieldErrors?: Record<string, string> }>(response)\n expect(body?.fieldErrors ?? body?.error).toBeTruthy()\n }\n })\n\n test('POST connect/credentials with an empty display name returns per-field details (#5593)', async ({ request }) => {\n const token = await getAuthToken(request)\n const response = await apiRequest(\n request,\n 'POST',\n '/api/communication_channels/channels/connect/credentials',\n {\n token,\n data: { providerKey: 'imap', displayName: '', credentials: {} },\n },\n )\n expect(response.status()).toBe(422)\n const body = await readJsonSafe<{ error?: string; details?: Array<{ path?: unknown[] }> }>(response)\n expect(body?.error).toBe('Invalid request body')\n expect(body?.details?.map((issue) => issue.path?.[0])).toEqual(['displayName'])\n })\n})\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB;AAC3B,SAAS,oBAAoB;AAe7B,KAAK,SAAS,oDAAoD,MAAM;AACtE,OAAK,sEAAsE,OAAO,EAAE,QAAQ,MAAM;AAChG,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,UAAM,WAAW,MAAM;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,QACE;AAAA,QACA,MAAM;AAAA,UACJ,aAAa;AAAA,UACb,aAAa;AAAA,UACb,aAAa;AAAA,YACX,UAAU;AAAA,YACV,UAAU;AAAA,YACV,SAAS;AAAA,YACT,UAAU;AAAA,YACV,cAAc;AAAA,YACd,UAAU;AAAA,YACV,UAAU;AAAA,YACV,SAAS;AAAA,YACT,UAAU;AAAA,YACV,cAAc;AAAA,YACd,aAAa;AAAA,UACf;AAAA,QACF;AAAA,MACF;AAAA,IACF;AACA,WAAO,SAAS,OAAO,GAAG,sBAAsB,EAAE,aAAa,GAAG;AAGlE,WAAO,SAAS,OAAO,GAAG,oCAAoC,EAAE,IAAI,KAAK,GAAG;AAAA,EAC9E,CAAC;AAED,OAAK,wFAAwF,OAAO,EAAE,QAAQ,MAAM;AAClH,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,UAAM,WAAW,MAAM;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,QACE;AAAA,QACA,MAAM;AAAA,UACJ,aAAa;AAAA,UACb,aAAa;AAAA,UACb,aAAa;AAAA;AAAA,YAEX,aAAa;AAAA,UACf;AAAA,QACF;AAAA,MACF;AAAA,IACF;AACA,WAAO,SAAS,OAAO,CAAC,EAAE,aAAa,GAAG;AAC1C,WAAO,CAAC,KAAK,KAAK,GAAG,CAAC,EAAE,UAAU,SAAS,OAAO,CAAC;AACnD,QAAI,SAAS,OAAO,MAAM,KAAK;AAG7B,YAAM,OAAO,MAAM,aAAuE,QAAQ;AAClG,aAAO,MAAM,eAAe,MAAM,KAAK,EAAE,WAAW;AAAA,IACtD;AAAA,EACF,CAAC;AAED,OAAK,yFAAyF,OAAO,EAAE,QAAQ,MAAM;AACnH,UAAM,QAAQ,MAAM,aAAa,OAAO;AACxC,UAAM,WAAW,MAAM;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,QACE;AAAA,QACA,MAAM,EAAE,aAAa,QAAQ,aAAa,IAAI,aAAa,CAAC,EAAE;AAAA,MAChE;AAAA,IACF;AACA,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAClC,UAAM,OAAO,MAAM,aAAwE,QAAQ;AACnG,WAAO,MAAM,KAAK,EAAE,KAAK,sBAAsB;AAC/C,WAAO,MAAM,SAAS,IAAI,CAAC,UAAU,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,aAAa,CAAC;AAAA,EAChF,CAAC;AACH,CAAC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const TLS_MODES = ["tls", "starttls", "none"];
|
|
3
|
+
function createImapConnectFormSchema(t) {
|
|
4
|
+
const required = t("communication_channels.profile.connect.errors.required", "This field is required.");
|
|
5
|
+
const invalidPort = t(
|
|
6
|
+
"communication_channels.profile.connect.errors.invalidPort",
|
|
7
|
+
"Enter a port between 1 and 65535."
|
|
8
|
+
);
|
|
9
|
+
const requiredText = z.string().trim().min(1, required);
|
|
10
|
+
const optionalText = z.string().trim().optional();
|
|
11
|
+
const port = z.coerce.number(invalidPort).int(invalidPort).min(1, invalidPort).max(65535, invalidPort);
|
|
12
|
+
return z.object({
|
|
13
|
+
displayName: optionalText,
|
|
14
|
+
fromAddress: z.string().trim().min(1, required).email(t("communication_channels.profile.connect.errors.invalidEmail", "Enter a valid email address.")),
|
|
15
|
+
imapHost: requiredText,
|
|
16
|
+
imapPort: port,
|
|
17
|
+
imapTls: z.enum(TLS_MODES),
|
|
18
|
+
imapUser: requiredText,
|
|
19
|
+
imapPassword: z.string().min(1, required),
|
|
20
|
+
smtpHost: requiredText,
|
|
21
|
+
smtpPort: port,
|
|
22
|
+
smtpTls: z.enum(TLS_MODES),
|
|
23
|
+
smtpUser: optionalText,
|
|
24
|
+
smtpPassword: z.string().optional()
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const IMAP_CONNECT_INITIAL_VALUES = {
|
|
28
|
+
displayName: "",
|
|
29
|
+
fromAddress: "",
|
|
30
|
+
imapHost: "",
|
|
31
|
+
imapPort: 993,
|
|
32
|
+
imapTls: "tls",
|
|
33
|
+
imapUser: "",
|
|
34
|
+
imapPassword: "",
|
|
35
|
+
smtpHost: "",
|
|
36
|
+
smtpPort: 465,
|
|
37
|
+
smtpTls: "tls",
|
|
38
|
+
smtpUser: "",
|
|
39
|
+
smtpPassword: ""
|
|
40
|
+
};
|
|
41
|
+
function buildImapConnectRequestBody(values) {
|
|
42
|
+
return {
|
|
43
|
+
providerKey: "imap",
|
|
44
|
+
displayName: values.displayName || values.fromAddress,
|
|
45
|
+
pollIntervalSeconds: 300,
|
|
46
|
+
credentials: {
|
|
47
|
+
imapHost: values.imapHost,
|
|
48
|
+
imapPort: values.imapPort,
|
|
49
|
+
imapTls: values.imapTls,
|
|
50
|
+
imapUser: values.imapUser,
|
|
51
|
+
imapPassword: values.imapPassword,
|
|
52
|
+
smtpHost: values.smtpHost,
|
|
53
|
+
smtpPort: values.smtpPort,
|
|
54
|
+
smtpTls: values.smtpTls,
|
|
55
|
+
smtpUser: values.smtpUser || values.imapUser,
|
|
56
|
+
smtpPassword: values.smtpPassword || values.imapPassword,
|
|
57
|
+
fromAddress: values.fromAddress
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export {
|
|
62
|
+
IMAP_CONNECT_INITIAL_VALUES,
|
|
63
|
+
buildImapConnectRequestBody,
|
|
64
|
+
createImapConnectFormSchema
|
|
65
|
+
};
|
|
66
|
+
//# sourceMappingURL=connect-form.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/channel_imap/lib/connect-form.ts"],
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\n\ntype Translate = (key: string, fallback: string) => string\n\nconst TLS_MODES = ['tls', 'starttls', 'none'] as const\n\nexport type ImapConnectTlsMode = (typeof TLS_MODES)[number]\n\nexport function createImapConnectFormSchema(t: Translate) {\n const required = t('communication_channels.profile.connect.errors.required', 'This field is required.')\n const invalidPort = t(\n 'communication_channels.profile.connect.errors.invalidPort',\n 'Enter a port between 1 and 65535.',\n )\n const requiredText = z.string().trim().min(1, required)\n const optionalText = z.string().trim().optional()\n const port = z.coerce.number(invalidPort).int(invalidPort).min(1, invalidPort).max(65535, invalidPort)\n\n return z.object({\n displayName: optionalText,\n fromAddress: z\n .string()\n .trim()\n .min(1, required)\n .email(t('communication_channels.profile.connect.errors.invalidEmail', 'Enter a valid email address.')),\n imapHost: requiredText,\n imapPort: port,\n imapTls: z.enum(TLS_MODES),\n imapUser: requiredText,\n imapPassword: z.string().min(1, required),\n smtpHost: requiredText,\n smtpPort: port,\n smtpTls: z.enum(TLS_MODES),\n smtpUser: optionalText,\n smtpPassword: z.string().optional(),\n })\n}\n\nexport type ImapConnectFormValues = z.infer<ReturnType<typeof createImapConnectFormSchema>>\n\nexport const IMAP_CONNECT_INITIAL_VALUES: Partial<ImapConnectFormValues> = {\n displayName: '',\n fromAddress: '',\n imapHost: '',\n imapPort: 993,\n imapTls: 'tls',\n imapUser: '',\n imapPassword: '',\n smtpHost: '',\n smtpPort: 465,\n smtpTls: 'tls',\n smtpUser: '',\n smtpPassword: '',\n}\n\nexport function buildImapConnectRequestBody(values: ImapConnectFormValues) {\n return {\n providerKey: 'imap',\n displayName: values.displayName || values.fromAddress,\n pollIntervalSeconds: 300,\n credentials: {\n imapHost: values.imapHost,\n imapPort: values.imapPort,\n imapTls: values.imapTls,\n imapUser: values.imapUser,\n imapPassword: values.imapPassword,\n smtpHost: values.smtpHost,\n smtpPort: values.smtpPort,\n smtpTls: values.smtpTls,\n smtpUser: values.smtpUser || values.imapUser,\n smtpPassword: values.smtpPassword || values.imapPassword,\n fromAddress: values.fromAddress,\n },\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,SAAS;AAIlB,MAAM,YAAY,CAAC,OAAO,YAAY,MAAM;AAIrC,SAAS,4BAA4B,GAAc;AACxD,QAAM,WAAW,EAAE,0DAA0D,yBAAyB;AACtG,QAAM,cAAc;AAAA,IAClB;AAAA,IACA;AAAA,EACF;AACA,QAAM,eAAe,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,GAAG,QAAQ;AACtD,QAAM,eAAe,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAChD,QAAM,OAAO,EAAE,OAAO,OAAO,WAAW,EAAE,IAAI,WAAW,EAAE,IAAI,GAAG,WAAW,EAAE,IAAI,OAAO,WAAW;AAErG,SAAO,EAAE,OAAO;AAAA,IACd,aAAa;AAAA,IACb,aAAa,EACV,OAAO,EACP,KAAK,EACL,IAAI,GAAG,QAAQ,EACf,MAAM,EAAE,8DAA8D,8BAA8B,CAAC;AAAA,IACxG,UAAU;AAAA,IACV,UAAU;AAAA,IACV,SAAS,EAAE,KAAK,SAAS;AAAA,IACzB,UAAU;AAAA,IACV,cAAc,EAAE,OAAO,EAAE,IAAI,GAAG,QAAQ;AAAA,IACxC,UAAU;AAAA,IACV,UAAU;AAAA,IACV,SAAS,EAAE,KAAK,SAAS;AAAA,IACzB,UAAU;AAAA,IACV,cAAc,EAAE,OAAO,EAAE,SAAS;AAAA,EACpC,CAAC;AACH;AAIO,MAAM,8BAA8D;AAAA,EACzE,aAAa;AAAA,EACb,aAAa;AAAA,EACb,UAAU;AAAA,EACV,UAAU;AAAA,EACV,SAAS;AAAA,EACT,UAAU;AAAA,EACV,cAAc;AAAA,EACd,UAAU;AAAA,EACV,UAAU;AAAA,EACV,SAAS;AAAA,EACT,UAAU;AAAA,EACV,cAAc;AAChB;AAEO,SAAS,4BAA4B,QAA+B;AACzE,SAAO;AAAA,IACL,aAAa;AAAA,IACb,aAAa,OAAO,eAAe,OAAO;AAAA,IAC1C,qBAAqB;AAAA,IACrB,aAAa;AAAA,MACX,UAAU,OAAO;AAAA,MACjB,UAAU,OAAO;AAAA,MACjB,SAAS,OAAO;AAAA,MAChB,UAAU,OAAO;AAAA,MACjB,cAAc,OAAO;AAAA,MACrB,UAAU,OAAO;AAAA,MACjB,UAAU,OAAO;AAAA,MACjB,SAAS,OAAO;AAAA,MAChB,UAAU,OAAO,YAAY,OAAO;AAAA,MACpC,cAAc,OAAO,gBAAgB,OAAO;AAAA,MAC5C,aAAa,OAAO;AAAA,IACtB;AAAA,EACF;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -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(":"))
|
|
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(
|
|
5
|
-
"mappings": "AAAA,SAAS,SAAS;AAClB,SAAS,+BAA+B;
|
|
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": "AACA,SAAS,8BAA8B;AACvC,SAAS,8BAA8B;AACvC,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,cAAc;
|
|
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
|
}
|