@open-mercato/channel-imap 0.6.6-develop.6465.1.019f0fb26f → 0.6.6-develop.6471.1.9ab5bdd79a
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/dist/modules/channel_imap/lib/adapter.js +6 -8
- package/dist/modules/channel_imap/lib/adapter.js.map +2 -2
- package/dist/modules/channel_imap/lib/smtp-client.js +3 -4
- package/dist/modules/channel_imap/lib/smtp-client.js.map +2 -2
- package/dist/modules/channel_imap/lib/validate-credentials.js +3 -1
- package/dist/modules/channel_imap/lib/validate-credentials.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/channel_imap/lib/adapter.ts +7 -8
- package/src/modules/channel_imap/lib/smtp-client.ts +4 -4
- package/src/modules/channel_imap/lib/validate-credentials.ts +4 -1
|
@@ -13,6 +13,8 @@ import { normalizeInboundImapMessage } from "./normalize-inbound.js";
|
|
|
13
13
|
import { validateImapCredentials } from "./validate-credentials.js";
|
|
14
14
|
import { emailResolveContact } from "@open-mercato/core/modules/communication_channels/lib/email-contact";
|
|
15
15
|
import { decodeCursor, encodeCursor } from "@open-mercato/core/modules/communication_channels/lib/email-mime";
|
|
16
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
17
|
+
const logger = createLogger("channel_imap");
|
|
16
18
|
class ImapChannelAdapter {
|
|
17
19
|
constructor() {
|
|
18
20
|
this.providerKey = "imap";
|
|
@@ -64,10 +66,7 @@ class ImapChannelAdapter {
|
|
|
64
66
|
await imap.appendSent(credentialsToConnection(credentials), result.raw);
|
|
65
67
|
}
|
|
66
68
|
} catch (appendError) {
|
|
67
|
-
|
|
68
|
-
"[internal] channel_imap: failed to append outbound message to Sent folder:",
|
|
69
|
-
appendError instanceof Error ? appendError.message : appendError
|
|
70
|
-
);
|
|
69
|
+
logger.warn("failed to append outbound message to Sent folder", { err: appendError });
|
|
71
70
|
}
|
|
72
71
|
return {
|
|
73
72
|
externalMessageId: result.messageId,
|
|
@@ -110,11 +109,10 @@ class ImapChannelAdapter {
|
|
|
110
109
|
const HARD_CAP = clampHardCap(input.limit);
|
|
111
110
|
const uidValidityMismatch = previousUidValidity !== void 0 && folderState.uidValidity !== void 0 && folderState.uidValidity !== previousUidValidity;
|
|
112
111
|
if (uidValidityMismatch) {
|
|
113
|
-
|
|
114
|
-
"[channel-imap] UIDVALIDITY changed for INBOX (was %s, now %s) \u2014 discarding cursor and re-bootstrapping",
|
|
112
|
+
logger.warn("UIDVALIDITY changed for INBOX \u2014 discarding cursor and re-bootstrapping", {
|
|
115
113
|
previousUidValidity,
|
|
116
|
-
folderState.uidValidity
|
|
117
|
-
);
|
|
114
|
+
uidValidity: folderState.uidValidity
|
|
115
|
+
});
|
|
118
116
|
}
|
|
119
117
|
const needsBootstrap = uidValidityMismatch || previousUidNext === void 0 || previousUidNext === null;
|
|
120
118
|
let fetched;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/channel_imap/lib/adapter.ts"],
|
|
4
|
-
"sourcesContent": ["import type {\n ChannelAdapter,\n ConvertOutboundInput,\n ChannelNativeContent,\n FetchHistoryInput,\n GetMessageStatusInput,\n HistoryPage,\n ImportHistoryInput,\n ImportHistoryPage,\n InboundMessage,\n MessageStatus,\n NormalizedInboundMessage,\n ResolveContactInput,\n ContactHint,\n SendMessageInput,\n SendMessageResult,\n ValidateCredentialsInput,\n ValidateCredentialsResult,\n VerifyWebhookInput,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport { imapCapabilities } from './capabilities'\nimport { imapCredentialsSchema, imapChannelStateSchema, type ImapCredentials, type ImapChannelState } from './credentials'\nimport {\n credentialsToConnection,\n getImapClient,\n} from './imap-client'\nimport {\n credentialsToSmtpConnection,\n getSmtpClient,\n} from './smtp-client'\nimport { convertOutboundForEmail } from './convert-outbound'\nimport { normalizeInboundImapMessage } from './normalize-inbound'\nimport { validateImapCredentials } from './validate-credentials'\nimport { emailResolveContact } from '@open-mercato/core/modules/communication_channels/lib/email-contact'\nimport { decodeCursor, encodeCursor } from '@open-mercato/core/modules/communication_channels/lib/email-mime'\n\n/**\n * IMAP+SMTP `ChannelAdapter`. Inbound is polling-driven (`realtimePush: false`),\n * outbound is SMTP. Threading is RFC2822 (In-Reply-To / References).\n *\n * Why this adapter omits some methods:\n * - `verifyWebhook` \u2014 IMAP has no webhook; we return a no-op event with\n * `eventType: 'other'` so the hub's webhook route returns 202 if anyone\n * ever POSTs at `/api/communication_channels/webhook/imap`.\n * - `getStatus` \u2014 IMAP has no delivery-status concept beyond `\\Seen`; we\n * return `{ status: 'sent' }` as a best-effort placeholder.\n * - No `sendReaction` / `editMessage` / `deleteMessage` \u2014 email doesn't\n * support these capabilities.\n */\nclass ImapChannelAdapter implements ChannelAdapter {\n readonly providerKey = 'imap'\n readonly channelType = 'email'\n readonly capabilities = imapCapabilities\n\n async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {\n const credentials = parseCredentialsOrThrow(input.credentials)\n\n // Reject attachments at the boundary BEFORE building the MIME body. The hub\n // passes attachments as URL pointers; until the IMAP/SMTP adapter wires a\n // fetcher (with size + content-type validation), inlining them is unsafe \u2014\n // a 0-byte attachment looks \"delivered\" but conveys nothing. Checking here\n // (rather than after conversion) avoids wasted MIME-build work and surfaces\n // the clearer \"attachments unsupported\" error even when recipients are also\n // missing. Documented in review M2 (2026-05-26) and tracked as a follow-up.\n if (Array.isArray(input.content.attachments) && input.content.attachments.length > 0) {\n return {\n externalMessageId: '',\n status: 'failed',\n error:\n '[internal] IMAP/SMTP adapter does not yet support attachments. Send the message without attachments or use a provider that supports them (Gmail).',\n }\n }\n\n let native: ChannelNativeContent\n try {\n native = await convertOutboundForEmail({\n body: input.content.html ?? input.content.text ?? '',\n bodyFormat: input.content.bodyFormat ?? (input.content.html ? 'html' : 'text'),\n attachments: input.content.attachments,\n channelMetadata: input.metadata,\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Outbound conversion failed'\n return { externalMessageId: '', status: 'failed', error: message }\n }\n const meta = (native.metadata ?? {}) as Record<string, unknown>\n const to = Array.isArray(meta.to) ? (meta.to as string[]) : []\n if (to.length === 0) {\n return { externalMessageId: '', status: 'failed', error: '[internal] Email send requires at least one recipient' }\n }\n\n const smtp = getSmtpClient()\n const result = await smtp.send(credentialsToSmtpConnection(credentials), {\n from: credentials.fromAddress,\n to,\n cc: Array.isArray(meta.cc) ? (meta.cc as string[]) : undefined,\n bcc: Array.isArray(meta.bcc) ? (meta.bcc as string[]) : undefined,\n subject: typeof meta.subject === 'string' ? (meta.subject as string) : undefined,\n text: native.content.text,\n html: native.content.html,\n inReplyTo: typeof meta.inReplyTo === 'string' ? (meta.inReplyTo as string) : undefined,\n references: Array.isArray(meta.references) ? (meta.references as string[]) : undefined,\n messageId: typeof meta.messageId === 'string' ? (meta.messageId as string) : undefined,\n })\n\n // Best-effort append to Sent \u2014 many servers auto-store via \"Submission\" but not all do.\n const imap = getImapClient()\n try {\n // Skip when the RFC2822 bytes are empty (MailComposer build failed upstream):\n // appending a 0-byte buffer would create a corrupt Sent-folder entry, and the\n // send itself already succeeded.\n if (result.raw.length > 0) {\n await imap.appendSent(credentialsToConnection(credentials), result.raw)\n }\n } catch (appendError) {\n // Best-effort: many servers auto-store sent mail via Submission, and the\n // Sent mailbox name is provider-specific (localized, or \"[Gmail]/Sent Mail\").\n // Log so operators can diagnose missing Sent-folder archival rather than\n // failing the send.\n console.warn(\n '[internal] channel_imap: failed to append outbound message to Sent folder:',\n appendError instanceof Error ? appendError.message : appendError,\n )\n }\n\n return {\n externalMessageId: result.messageId,\n conversationId: input.conversationId,\n status: 'sent',\n metadata: { response: result.response },\n }\n }\n\n async verifyWebhook(_input: VerifyWebhookInput): Promise<InboundMessage> {\n return { raw: {}, eventType: 'other', metadata: { reason: 'imap-does-not-use-webhooks' } }\n }\n\n async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {\n return { status: 'sent' }\n }\n\n async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {\n return convertOutboundForEmail(input)\n }\n\n async normalizeInbound(raw: InboundMessage): Promise<NormalizedInboundMessage> {\n const rawBuffer = pickRawMimeBuffer(raw)\n const accountIdentifier = pickAccountIdentifier(raw)\n const uid = pickUid(raw)\n return normalizeInboundImapMessage({\n rawMessage: rawBuffer,\n uid,\n accountIdentifier,\n })\n }\n\n async validateCredentials(input: ValidateCredentialsInput): Promise<ValidateCredentialsResult> {\n return validateImapCredentials(input.credentials)\n }\n\n async fetchHistory(input: FetchHistoryInput): Promise<HistoryPage> {\n const credentials = parseCredentialsOrThrow(input.credentials)\n const channelState = imapChannelStateSchema.parse(input.channelState ?? {}) satisfies ImapChannelState\n const imap = getImapClient()\n const connection = credentialsToConnection(credentials)\n\n // Spec B \u00A7 Bounded, cursor-driven IMAP inbound:\n // - Bootstrap (no cursor): SELECT INBOX, persist UIDVALIDITY + UIDNEXT,\n // return ZERO messages. Backlog import happens via the explicit\n // `/import-history` endpoint, not via the silent connect flow.\n // - Incremental (cursor exists): UID FETCH `previousUidNext:*`, capped\n // at HARD_CAP = 200. If more available, set `hasMore: true` so the\n // hub re-enqueues immediately and drains the backlog.\n // - UIDVALIDITY mismatch: discard cursor and treat as bootstrap (the\n // mailbox was recreated or renamed; we cannot trust the prior cursor).\n const folderState = await imap.selectInbox(connection)\n const previousUidValidity = toNumberOrUndefined(channelState.uidValidity)\n const previousUidNext = toNumberOrUndefined(channelState.uidNext)\n const serverUidNext = toNumberOrUndefined(folderState.uidNext)\n const HARD_CAP = clampHardCap(input.limit)\n\n const uidValidityMismatch =\n previousUidValidity !== undefined &&\n folderState.uidValidity !== undefined &&\n folderState.uidValidity !== previousUidValidity\n if (uidValidityMismatch) {\n console.warn(\n '[channel-imap] UIDVALIDITY changed for INBOX (was %s, now %s) \u2014 discarding cursor and re-bootstrapping',\n previousUidValidity,\n folderState.uidValidity,\n )\n }\n const needsBootstrap =\n uidValidityMismatch || previousUidNext === undefined || previousUidNext === null\n\n let fetched: { uid: number; rawBody: Buffer; internalDate?: Date; flags?: string[] }[]\n let hasMore = false\n if (needsBootstrap) {\n // \u2500\u2500 Bootstrap: persist cursor only, fetch zero messages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Spec B \u00A7 Bootstrap. The \"1M inbox\" failure mode is fixed by\n // construction: a fresh user sees zero history until they explicitly\n // request `/import-history`. Set `hasMore: false` so the poll worker\n // does NOT immediately re-enqueue.\n fetched = []\n hasMore = false\n } else if (previousUidNext !== undefined && serverUidNext !== undefined && previousUidNext >= serverUidNext) {\n // \u2500\u2500 Idle: UIDNEXT did not advance, so there is no new mail \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Skip the FETCH entirely. IMAP `<n>:*` always matches at least the\n // highest existing UID, so an idle mailbox would otherwise re-fetch and\n // re-normalize one already-ingested message every tick. The cursor is\n // retained downstream (an empty fetch does not advance it).\n fetched = []\n hasMore = false\n } else {\n // \u2500\u2500 Incremental: UID FETCH previousUidNext:* up to HARD_CAP \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // On a mature mailbox this is typically 0-N UIDs. The HARD_CAP bounds\n // the per-poll wall-clock + DB transaction size; if more remain, the\n // hub re-enqueues us immediately via `hasMore: true`.\n const range = `${previousUidNext}:*`\n // Fetch up to HARD_CAP + 1 so we can detect whether more remain\n // without paying for an extra round-trip later.\n const probeLimit = HARD_CAP + 1\n const raw = await imap.fetchUidRange(connection, range, { limit: probeLimit })\n if (raw.length > HARD_CAP) {\n fetched = raw.slice(0, HARD_CAP)\n hasMore = true\n } else {\n fetched = raw\n hasMore = false\n }\n }\n\n const messages: NormalizedInboundMessage[] = []\n for (const item of fetched) {\n const normalized = await normalizeInboundImapMessage({\n rawMessage: item.rawBody,\n uid: item.uid,\n accountIdentifier: credentials.fromAddress,\n fallbackDate: item.internalDate,\n })\n messages.push(normalized)\n }\n\n // Cursor advancement contract:\n // - Bootstrap: persist the server's current UIDNEXT so the next poll\n // becomes incremental from this point onward (intentionally skips the\n // pre-existing backlog; use `/import-history` to pull it).\n // - Incremental: persist `highestFetchedUid + 1` \u2014 NEVER the server's\n // UIDNEXT. When `fetched` is empty we retain `previousUidNext` so the\n // next poll resumes from the same point.\n const advancedUidNext = (() => {\n if (needsBootstrap) return serverUidNext\n if (fetched.length === 0) return previousUidNext\n const highest = fetched.reduce((max, item) => (item.uid > max ? item.uid : max), 0)\n // Anchor the cursor to the highest UID we ACTUALLY fetched \u2014 never to the\n // server's UIDNEXT. Providers like Gmail report a UIDNEXT that runs ahead\n // of the highest message currently in the folder (UID gaps from\n // labels/threads, or a message materialising into INBOX at a UID below\n // UIDNEXT). Jumping the cursor to `serverUidNext` then steps over any\n // INBOX message that sits below it: that message lands permanently below\n // the cursor and is never fetched again \u2014 the bug that silently dropped\n // inbound replies (UID 61979 skipped while cursor jumped to 61981).\n // `highest + 1` guarantees we never step over an unfetched message; if the\n // server's UIDNEXT is higher, the next poll just re-scans `highest+1:*`\n // (idempotent \u2014 the hub dedups on (channel_id, external_message_id)).\n return highest + 1\n })()\n const nextChannelState: ImapChannelState = {\n uidValidity: folderState.uidValidity ?? previousUidValidity,\n uidNext: advancedUidNext,\n lastFolder: 'INBOX',\n }\n\n // The hub's polling worker re-reads cursor through `fetchHistory`. We embed the\n // next-poll state in `nextCursor` (base64-encoded JSON) so workers can persist\n // it onto `CommunicationChannel.channelState` without depending on a hub-specific\n // contract beyond the existing `HistoryPage` shape.\n const nextCursor = encodeCursor(nextChannelState)\n return { messages, nextCursor, hasMore }\n }\n\n async importHistory(input: ImportHistoryInput): Promise<ImportHistoryPage> {\n const credentials = parseCredentialsOrThrow(input.credentials)\n const connection = credentialsToConnection(credentials)\n const imap = getImapClient()\n\n const sinceDaysRaw = Number.isFinite(input.sinceDays) ? Math.trunc(input.sinceDays) : 30\n const sinceDays = Math.max(1, Math.min(365, sinceDaysRaw))\n const sinceDate = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000)\n\n const maxMessagesRaw = Number.isFinite(input.maxMessages) ? Math.trunc(input.maxMessages as number) : 1000\n const maxMessages = Math.max(1, Math.min(5000, maxMessagesRaw))\n\n const PAGE_SIZE = clampHardCap(undefined)\n\n // Resume previous page or perform initial SEARCH on first call. The cursor\n // encodes the full remaining UID list discovered server-side so subsequent\n // pages don't re-issue SEARCH (which on large mailboxes is expensive).\n let allUids: number[]\n let remainingUids: number[]\n let collectedSoFar: number\n let totalCandidates: number | undefined\n const cursor = decodeImportCursor(input.cursor)\n if (cursor) {\n remainingUids = cursor.remaining\n collectedSoFar = cursor.collected\n totalCandidates = cursor.total\n allUids = cursor.remaining\n } else {\n // FROM-chunking: SEARCH with very long OR chains can blow imapflow's\n // tag-buffer; chunk to \u226430 senders and union the results. When\n // contactEmails is empty we issue a single SINCE-only search.\n const senders = (input.contactEmails ?? []).filter((s): s is string => typeof s === 'string' && s.includes('@'))\n const uidSet = new Set<number>()\n if (senders.length === 0) {\n const uids = await imap.searchUidsByFromAndSince(connection, { sinceDate })\n for (const uid of uids) uidSet.add(uid)\n } else {\n const CHUNK_SIZE = 30\n for (let i = 0; i < senders.length; i += CHUNK_SIZE) {\n const chunk = senders.slice(i, i + CHUNK_SIZE)\n const uids = await imap.searchUidsByFromAndSince(connection, { fromAddresses: chunk, sinceDate })\n for (const uid of uids) uidSet.add(uid)\n }\n }\n // Process newest first (highest UIDs ~= most recent on standard servers).\n allUids = Array.from(uidSet).sort((a, b) => b - a).slice(0, maxMessages)\n remainingUids = allUids\n collectedSoFar = 0\n totalCandidates = allUids.length\n }\n\n if (remainingUids.length === 0) {\n return { messages: [], hasMore: false, totalCandidates }\n }\n\n const batchUids = remainingUids.slice(0, PAGE_SIZE)\n const stillRemaining = remainingUids.slice(PAGE_SIZE)\n const uidSetExpression = batchUids.join(',')\n const fetched = await imap.fetchUidRange(connection, uidSetExpression, { limit: PAGE_SIZE })\n\n const messages: NormalizedInboundMessage[] = []\n for (const item of fetched) {\n const normalized = await normalizeInboundImapMessage({\n rawMessage: item.rawBody,\n uid: item.uid,\n accountIdentifier: credentials.fromAddress,\n fallbackDate: item.internalDate,\n })\n messages.push(normalized)\n }\n\n const newCollected = collectedSoFar + messages.length\n const hasMore = stillRemaining.length > 0 && newCollected < maxMessages\n const nextCursor = hasMore\n ? encodeImportCursor({ remaining: stillRemaining, collected: newCollected, total: totalCandidates })\n : undefined\n\n return { messages, nextCursor, hasMore, totalCandidates }\n }\n\n async resolveContact(input: ResolveContactInput): Promise<ContactHint | null> {\n return emailResolveContact(input)\n }\n}\n\nfunction parseCredentialsOrThrow(value: unknown): ImapCredentials {\n const parsed = imapCredentialsSchema.safeParse(value)\n if (!parsed.success) {\n const first = parsed.error.issues[0]\n throw new Error(`Invalid IMAP credentials: ${first?.message ?? 'unknown validation error'}`)\n }\n return parsed.data\n}\n\nfunction pickRawMimeBuffer(raw: InboundMessage): Buffer {\n const candidate = raw.raw as { rawBody?: unknown; mime?: unknown }\n const value = candidate?.rawBody ?? candidate?.mime ?? raw.raw\n if (Buffer.isBuffer(value)) return value\n if (value instanceof Uint8Array) return Buffer.from(value)\n if (typeof value === 'string') return Buffer.from(value, 'utf-8')\n throw new Error('[internal] IMAP normalizeInbound requires `raw.rawBody` to be a Buffer or string MIME payload')\n}\n\nfunction pickAccountIdentifier(raw: InboundMessage): string {\n const candidate = raw.raw as { accountIdentifier?: unknown }\n const id = typeof candidate?.accountIdentifier === 'string' ? candidate.accountIdentifier : undefined\n return id ?? 'unknown@imap'\n}\n\nfunction pickUid(raw: InboundMessage): number | undefined {\n const candidate = raw.raw as { uid?: unknown }\n return typeof candidate?.uid === 'number' ? candidate.uid : undefined\n}\n\n/**\n * Spec B \u00A7 HARD_CAP. Bound each poll's wall-clock + DB transaction size.\n * Honor the caller's `limit` hint but never exceed `HARD_CAP_MAX`. A\n * single poll will fetch at most this many UIDs; if more remain we set\n * `hasMore: true` and the hub re-enqueues immediately.\n *\n * Configurable via `OM_CHANNEL_IMAP_HARD_CAP_PER_POLL` (default 200).\n */\nfunction clampHardCap(callerLimit: number | undefined): number {\n const envOverride = Number.parseInt(process.env.OM_CHANNEL_IMAP_HARD_CAP_PER_POLL ?? '', 10)\n const HARD_CAP_MAX = Number.isFinite(envOverride) && envOverride > 0 ? envOverride : 200\n if (typeof callerLimit === 'number' && callerLimit > 0) {\n return Math.min(callerLimit, HARD_CAP_MAX)\n }\n return HARD_CAP_MAX\n}\n\ninterface ImportCursor {\n remaining: number[]\n collected: number\n total?: number\n}\n\nfunction encodeImportCursor(cursor: ImportCursor): string {\n return encodeCursor(cursor)\n}\n\nfunction decodeImportCursor(value: string | undefined): ImportCursor | null {\n const parsed = decodeCursor(value)\n if (!parsed || typeof parsed !== 'object') return null\n const obj = parsed as { remaining?: unknown; collected?: unknown; total?: unknown }\n const remaining = Array.isArray(obj.remaining)\n ? obj.remaining.filter((n): n is number => typeof n === 'number' && Number.isFinite(n))\n : []\n const collected = typeof obj.collected === 'number' ? obj.collected : 0\n const total = typeof obj.total === 'number' ? obj.total : undefined\n return { remaining, collected, total }\n}\n\nfunction toNumberOrUndefined(value: unknown): number | undefined {\n if (typeof value === 'number') return value\n if (typeof value === 'string' && value.length > 0) {\n const n = Number(value)\n return Number.isFinite(n) ? n : undefined\n }\n return undefined\n}\n\nlet cachedAdapter: ImapChannelAdapter | null = null\n\nexport function getImapChannelAdapter(): ImapChannelAdapter {\n if (!cachedAdapter) cachedAdapter = new ImapChannelAdapter()\n return cachedAdapter\n}\n\nexport { ImapChannelAdapter }\n"],
|
|
5
|
-
"mappings": "AAoBA,SAAS,wBAAwB;AACjC,SAAS,uBAAuB,8BAA2E;AAC3G;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,+BAA+B;AACxC,SAAS,mCAAmC;AAC5C,SAAS,+BAA+B;AACxC,SAAS,2BAA2B;AACpC,SAAS,cAAc,oBAAoB;
|
|
4
|
+
"sourcesContent": ["import type {\n ChannelAdapter,\n ConvertOutboundInput,\n ChannelNativeContent,\n FetchHistoryInput,\n GetMessageStatusInput,\n HistoryPage,\n ImportHistoryInput,\n ImportHistoryPage,\n InboundMessage,\n MessageStatus,\n NormalizedInboundMessage,\n ResolveContactInput,\n ContactHint,\n SendMessageInput,\n SendMessageResult,\n ValidateCredentialsInput,\n ValidateCredentialsResult,\n VerifyWebhookInput,\n} from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport { imapCapabilities } from './capabilities'\nimport { imapCredentialsSchema, imapChannelStateSchema, type ImapCredentials, type ImapChannelState } from './credentials'\nimport {\n credentialsToConnection,\n getImapClient,\n} from './imap-client'\nimport {\n credentialsToSmtpConnection,\n getSmtpClient,\n} from './smtp-client'\nimport { convertOutboundForEmail } from './convert-outbound'\nimport { normalizeInboundImapMessage } from './normalize-inbound'\nimport { validateImapCredentials } from './validate-credentials'\nimport { emailResolveContact } from '@open-mercato/core/modules/communication_channels/lib/email-contact'\nimport { decodeCursor, encodeCursor } from '@open-mercato/core/modules/communication_channels/lib/email-mime'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('channel_imap')\n\n/**\n * IMAP+SMTP `ChannelAdapter`. Inbound is polling-driven (`realtimePush: false`),\n * outbound is SMTP. Threading is RFC2822 (In-Reply-To / References).\n *\n * Why this adapter omits some methods:\n * - `verifyWebhook` \u2014 IMAP has no webhook; we return a no-op event with\n * `eventType: 'other'` so the hub's webhook route returns 202 if anyone\n * ever POSTs at `/api/communication_channels/webhook/imap`.\n * - `getStatus` \u2014 IMAP has no delivery-status concept beyond `\\Seen`; we\n * return `{ status: 'sent' }` as a best-effort placeholder.\n * - No `sendReaction` / `editMessage` / `deleteMessage` \u2014 email doesn't\n * support these capabilities.\n */\nclass ImapChannelAdapter implements ChannelAdapter {\n readonly providerKey = 'imap'\n readonly channelType = 'email'\n readonly capabilities = imapCapabilities\n\n async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {\n const credentials = parseCredentialsOrThrow(input.credentials)\n\n // Reject attachments at the boundary BEFORE building the MIME body. The hub\n // passes attachments as URL pointers; until the IMAP/SMTP adapter wires a\n // fetcher (with size + content-type validation), inlining them is unsafe \u2014\n // a 0-byte attachment looks \"delivered\" but conveys nothing. Checking here\n // (rather than after conversion) avoids wasted MIME-build work and surfaces\n // the clearer \"attachments unsupported\" error even when recipients are also\n // missing. Documented in review M2 (2026-05-26) and tracked as a follow-up.\n if (Array.isArray(input.content.attachments) && input.content.attachments.length > 0) {\n return {\n externalMessageId: '',\n status: 'failed',\n error:\n '[internal] IMAP/SMTP adapter does not yet support attachments. Send the message without attachments or use a provider that supports them (Gmail).',\n }\n }\n\n let native: ChannelNativeContent\n try {\n native = await convertOutboundForEmail({\n body: input.content.html ?? input.content.text ?? '',\n bodyFormat: input.content.bodyFormat ?? (input.content.html ? 'html' : 'text'),\n attachments: input.content.attachments,\n channelMetadata: input.metadata,\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Outbound conversion failed'\n return { externalMessageId: '', status: 'failed', error: message }\n }\n const meta = (native.metadata ?? {}) as Record<string, unknown>\n const to = Array.isArray(meta.to) ? (meta.to as string[]) : []\n if (to.length === 0) {\n return { externalMessageId: '', status: 'failed', error: '[internal] Email send requires at least one recipient' }\n }\n\n const smtp = getSmtpClient()\n const result = await smtp.send(credentialsToSmtpConnection(credentials), {\n from: credentials.fromAddress,\n to,\n cc: Array.isArray(meta.cc) ? (meta.cc as string[]) : undefined,\n bcc: Array.isArray(meta.bcc) ? (meta.bcc as string[]) : undefined,\n subject: typeof meta.subject === 'string' ? (meta.subject as string) : undefined,\n text: native.content.text,\n html: native.content.html,\n inReplyTo: typeof meta.inReplyTo === 'string' ? (meta.inReplyTo as string) : undefined,\n references: Array.isArray(meta.references) ? (meta.references as string[]) : undefined,\n messageId: typeof meta.messageId === 'string' ? (meta.messageId as string) : undefined,\n })\n\n // Best-effort append to Sent \u2014 many servers auto-store via \"Submission\" but not all do.\n const imap = getImapClient()\n try {\n // Skip when the RFC2822 bytes are empty (MailComposer build failed upstream):\n // appending a 0-byte buffer would create a corrupt Sent-folder entry, and the\n // send itself already succeeded.\n if (result.raw.length > 0) {\n await imap.appendSent(credentialsToConnection(credentials), result.raw)\n }\n } catch (appendError) {\n // Best-effort: many servers auto-store sent mail via Submission, and the\n // Sent mailbox name is provider-specific (localized, or \"[Gmail]/Sent Mail\").\n // Log so operators can diagnose missing Sent-folder archival rather than\n // failing the send.\n logger.warn('failed to append outbound message to Sent folder', { err: appendError })\n }\n\n return {\n externalMessageId: result.messageId,\n conversationId: input.conversationId,\n status: 'sent',\n metadata: { response: result.response },\n }\n }\n\n async verifyWebhook(_input: VerifyWebhookInput): Promise<InboundMessage> {\n return { raw: {}, eventType: 'other', metadata: { reason: 'imap-does-not-use-webhooks' } }\n }\n\n async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {\n return { status: 'sent' }\n }\n\n async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {\n return convertOutboundForEmail(input)\n }\n\n async normalizeInbound(raw: InboundMessage): Promise<NormalizedInboundMessage> {\n const rawBuffer = pickRawMimeBuffer(raw)\n const accountIdentifier = pickAccountIdentifier(raw)\n const uid = pickUid(raw)\n return normalizeInboundImapMessage({\n rawMessage: rawBuffer,\n uid,\n accountIdentifier,\n })\n }\n\n async validateCredentials(input: ValidateCredentialsInput): Promise<ValidateCredentialsResult> {\n return validateImapCredentials(input.credentials)\n }\n\n async fetchHistory(input: FetchHistoryInput): Promise<HistoryPage> {\n const credentials = parseCredentialsOrThrow(input.credentials)\n const channelState = imapChannelStateSchema.parse(input.channelState ?? {}) satisfies ImapChannelState\n const imap = getImapClient()\n const connection = credentialsToConnection(credentials)\n\n // Spec B \u00A7 Bounded, cursor-driven IMAP inbound:\n // - Bootstrap (no cursor): SELECT INBOX, persist UIDVALIDITY + UIDNEXT,\n // return ZERO messages. Backlog import happens via the explicit\n // `/import-history` endpoint, not via the silent connect flow.\n // - Incremental (cursor exists): UID FETCH `previousUidNext:*`, capped\n // at HARD_CAP = 200. If more available, set `hasMore: true` so the\n // hub re-enqueues immediately and drains the backlog.\n // - UIDVALIDITY mismatch: discard cursor and treat as bootstrap (the\n // mailbox was recreated or renamed; we cannot trust the prior cursor).\n const folderState = await imap.selectInbox(connection)\n const previousUidValidity = toNumberOrUndefined(channelState.uidValidity)\n const previousUidNext = toNumberOrUndefined(channelState.uidNext)\n const serverUidNext = toNumberOrUndefined(folderState.uidNext)\n const HARD_CAP = clampHardCap(input.limit)\n\n const uidValidityMismatch =\n previousUidValidity !== undefined &&\n folderState.uidValidity !== undefined &&\n folderState.uidValidity !== previousUidValidity\n if (uidValidityMismatch) {\n logger.warn('UIDVALIDITY changed for INBOX \u2014 discarding cursor and re-bootstrapping', {\n previousUidValidity,\n uidValidity: folderState.uidValidity,\n })\n }\n const needsBootstrap =\n uidValidityMismatch || previousUidNext === undefined || previousUidNext === null\n\n let fetched: { uid: number; rawBody: Buffer; internalDate?: Date; flags?: string[] }[]\n let hasMore = false\n if (needsBootstrap) {\n // \u2500\u2500 Bootstrap: persist cursor only, fetch zero messages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Spec B \u00A7 Bootstrap. The \"1M inbox\" failure mode is fixed by\n // construction: a fresh user sees zero history until they explicitly\n // request `/import-history`. Set `hasMore: false` so the poll worker\n // does NOT immediately re-enqueue.\n fetched = []\n hasMore = false\n } else if (previousUidNext !== undefined && serverUidNext !== undefined && previousUidNext >= serverUidNext) {\n // \u2500\u2500 Idle: UIDNEXT did not advance, so there is no new mail \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Skip the FETCH entirely. IMAP `<n>:*` always matches at least the\n // highest existing UID, so an idle mailbox would otherwise re-fetch and\n // re-normalize one already-ingested message every tick. The cursor is\n // retained downstream (an empty fetch does not advance it).\n fetched = []\n hasMore = false\n } else {\n // \u2500\u2500 Incremental: UID FETCH previousUidNext:* up to HARD_CAP \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // On a mature mailbox this is typically 0-N UIDs. The HARD_CAP bounds\n // the per-poll wall-clock + DB transaction size; if more remain, the\n // hub re-enqueues us immediately via `hasMore: true`.\n const range = `${previousUidNext}:*`\n // Fetch up to HARD_CAP + 1 so we can detect whether more remain\n // without paying for an extra round-trip later.\n const probeLimit = HARD_CAP + 1\n const raw = await imap.fetchUidRange(connection, range, { limit: probeLimit })\n if (raw.length > HARD_CAP) {\n fetched = raw.slice(0, HARD_CAP)\n hasMore = true\n } else {\n fetched = raw\n hasMore = false\n }\n }\n\n const messages: NormalizedInboundMessage[] = []\n for (const item of fetched) {\n const normalized = await normalizeInboundImapMessage({\n rawMessage: item.rawBody,\n uid: item.uid,\n accountIdentifier: credentials.fromAddress,\n fallbackDate: item.internalDate,\n })\n messages.push(normalized)\n }\n\n // Cursor advancement contract:\n // - Bootstrap: persist the server's current UIDNEXT so the next poll\n // becomes incremental from this point onward (intentionally skips the\n // pre-existing backlog; use `/import-history` to pull it).\n // - Incremental: persist `highestFetchedUid + 1` \u2014 NEVER the server's\n // UIDNEXT. When `fetched` is empty we retain `previousUidNext` so the\n // next poll resumes from the same point.\n const advancedUidNext = (() => {\n if (needsBootstrap) return serverUidNext\n if (fetched.length === 0) return previousUidNext\n const highest = fetched.reduce((max, item) => (item.uid > max ? item.uid : max), 0)\n // Anchor the cursor to the highest UID we ACTUALLY fetched \u2014 never to the\n // server's UIDNEXT. Providers like Gmail report a UIDNEXT that runs ahead\n // of the highest message currently in the folder (UID gaps from\n // labels/threads, or a message materialising into INBOX at a UID below\n // UIDNEXT). Jumping the cursor to `serverUidNext` then steps over any\n // INBOX message that sits below it: that message lands permanently below\n // the cursor and is never fetched again \u2014 the bug that silently dropped\n // inbound replies (UID 61979 skipped while cursor jumped to 61981).\n // `highest + 1` guarantees we never step over an unfetched message; if the\n // server's UIDNEXT is higher, the next poll just re-scans `highest+1:*`\n // (idempotent \u2014 the hub dedups on (channel_id, external_message_id)).\n return highest + 1\n })()\n const nextChannelState: ImapChannelState = {\n uidValidity: folderState.uidValidity ?? previousUidValidity,\n uidNext: advancedUidNext,\n lastFolder: 'INBOX',\n }\n\n // The hub's polling worker re-reads cursor through `fetchHistory`. We embed the\n // next-poll state in `nextCursor` (base64-encoded JSON) so workers can persist\n // it onto `CommunicationChannel.channelState` without depending on a hub-specific\n // contract beyond the existing `HistoryPage` shape.\n const nextCursor = encodeCursor(nextChannelState)\n return { messages, nextCursor, hasMore }\n }\n\n async importHistory(input: ImportHistoryInput): Promise<ImportHistoryPage> {\n const credentials = parseCredentialsOrThrow(input.credentials)\n const connection = credentialsToConnection(credentials)\n const imap = getImapClient()\n\n const sinceDaysRaw = Number.isFinite(input.sinceDays) ? Math.trunc(input.sinceDays) : 30\n const sinceDays = Math.max(1, Math.min(365, sinceDaysRaw))\n const sinceDate = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000)\n\n const maxMessagesRaw = Number.isFinite(input.maxMessages) ? Math.trunc(input.maxMessages as number) : 1000\n const maxMessages = Math.max(1, Math.min(5000, maxMessagesRaw))\n\n const PAGE_SIZE = clampHardCap(undefined)\n\n // Resume previous page or perform initial SEARCH on first call. The cursor\n // encodes the full remaining UID list discovered server-side so subsequent\n // pages don't re-issue SEARCH (which on large mailboxes is expensive).\n let allUids: number[]\n let remainingUids: number[]\n let collectedSoFar: number\n let totalCandidates: number | undefined\n const cursor = decodeImportCursor(input.cursor)\n if (cursor) {\n remainingUids = cursor.remaining\n collectedSoFar = cursor.collected\n totalCandidates = cursor.total\n allUids = cursor.remaining\n } else {\n // FROM-chunking: SEARCH with very long OR chains can blow imapflow's\n // tag-buffer; chunk to \u226430 senders and union the results. When\n // contactEmails is empty we issue a single SINCE-only search.\n const senders = (input.contactEmails ?? []).filter((s): s is string => typeof s === 'string' && s.includes('@'))\n const uidSet = new Set<number>()\n if (senders.length === 0) {\n const uids = await imap.searchUidsByFromAndSince(connection, { sinceDate })\n for (const uid of uids) uidSet.add(uid)\n } else {\n const CHUNK_SIZE = 30\n for (let i = 0; i < senders.length; i += CHUNK_SIZE) {\n const chunk = senders.slice(i, i + CHUNK_SIZE)\n const uids = await imap.searchUidsByFromAndSince(connection, { fromAddresses: chunk, sinceDate })\n for (const uid of uids) uidSet.add(uid)\n }\n }\n // Process newest first (highest UIDs ~= most recent on standard servers).\n allUids = Array.from(uidSet).sort((a, b) => b - a).slice(0, maxMessages)\n remainingUids = allUids\n collectedSoFar = 0\n totalCandidates = allUids.length\n }\n\n if (remainingUids.length === 0) {\n return { messages: [], hasMore: false, totalCandidates }\n }\n\n const batchUids = remainingUids.slice(0, PAGE_SIZE)\n const stillRemaining = remainingUids.slice(PAGE_SIZE)\n const uidSetExpression = batchUids.join(',')\n const fetched = await imap.fetchUidRange(connection, uidSetExpression, { limit: PAGE_SIZE })\n\n const messages: NormalizedInboundMessage[] = []\n for (const item of fetched) {\n const normalized = await normalizeInboundImapMessage({\n rawMessage: item.rawBody,\n uid: item.uid,\n accountIdentifier: credentials.fromAddress,\n fallbackDate: item.internalDate,\n })\n messages.push(normalized)\n }\n\n const newCollected = collectedSoFar + messages.length\n const hasMore = stillRemaining.length > 0 && newCollected < maxMessages\n const nextCursor = hasMore\n ? encodeImportCursor({ remaining: stillRemaining, collected: newCollected, total: totalCandidates })\n : undefined\n\n return { messages, nextCursor, hasMore, totalCandidates }\n }\n\n async resolveContact(input: ResolveContactInput): Promise<ContactHint | null> {\n return emailResolveContact(input)\n }\n}\n\nfunction parseCredentialsOrThrow(value: unknown): ImapCredentials {\n const parsed = imapCredentialsSchema.safeParse(value)\n if (!parsed.success) {\n const first = parsed.error.issues[0]\n throw new Error(`Invalid IMAP credentials: ${first?.message ?? 'unknown validation error'}`)\n }\n return parsed.data\n}\n\nfunction pickRawMimeBuffer(raw: InboundMessage): Buffer {\n const candidate = raw.raw as { rawBody?: unknown; mime?: unknown }\n const value = candidate?.rawBody ?? candidate?.mime ?? raw.raw\n if (Buffer.isBuffer(value)) return value\n if (value instanceof Uint8Array) return Buffer.from(value)\n if (typeof value === 'string') return Buffer.from(value, 'utf-8')\n throw new Error('[internal] IMAP normalizeInbound requires `raw.rawBody` to be a Buffer or string MIME payload')\n}\n\nfunction pickAccountIdentifier(raw: InboundMessage): string {\n const candidate = raw.raw as { accountIdentifier?: unknown }\n const id = typeof candidate?.accountIdentifier === 'string' ? candidate.accountIdentifier : undefined\n return id ?? 'unknown@imap'\n}\n\nfunction pickUid(raw: InboundMessage): number | undefined {\n const candidate = raw.raw as { uid?: unknown }\n return typeof candidate?.uid === 'number' ? candidate.uid : undefined\n}\n\n/**\n * Spec B \u00A7 HARD_CAP. Bound each poll's wall-clock + DB transaction size.\n * Honor the caller's `limit` hint but never exceed `HARD_CAP_MAX`. A\n * single poll will fetch at most this many UIDs; if more remain we set\n * `hasMore: true` and the hub re-enqueues immediately.\n *\n * Configurable via `OM_CHANNEL_IMAP_HARD_CAP_PER_POLL` (default 200).\n */\nfunction clampHardCap(callerLimit: number | undefined): number {\n const envOverride = Number.parseInt(process.env.OM_CHANNEL_IMAP_HARD_CAP_PER_POLL ?? '', 10)\n const HARD_CAP_MAX = Number.isFinite(envOverride) && envOverride > 0 ? envOverride : 200\n if (typeof callerLimit === 'number' && callerLimit > 0) {\n return Math.min(callerLimit, HARD_CAP_MAX)\n }\n return HARD_CAP_MAX\n}\n\ninterface ImportCursor {\n remaining: number[]\n collected: number\n total?: number\n}\n\nfunction encodeImportCursor(cursor: ImportCursor): string {\n return encodeCursor(cursor)\n}\n\nfunction decodeImportCursor(value: string | undefined): ImportCursor | null {\n const parsed = decodeCursor(value)\n if (!parsed || typeof parsed !== 'object') return null\n const obj = parsed as { remaining?: unknown; collected?: unknown; total?: unknown }\n const remaining = Array.isArray(obj.remaining)\n ? obj.remaining.filter((n): n is number => typeof n === 'number' && Number.isFinite(n))\n : []\n const collected = typeof obj.collected === 'number' ? obj.collected : 0\n const total = typeof obj.total === 'number' ? obj.total : undefined\n return { remaining, collected, total }\n}\n\nfunction toNumberOrUndefined(value: unknown): number | undefined {\n if (typeof value === 'number') return value\n if (typeof value === 'string' && value.length > 0) {\n const n = Number(value)\n return Number.isFinite(n) ? n : undefined\n }\n return undefined\n}\n\nlet cachedAdapter: ImapChannelAdapter | null = null\n\nexport function getImapChannelAdapter(): ImapChannelAdapter {\n if (!cachedAdapter) cachedAdapter = new ImapChannelAdapter()\n return cachedAdapter\n}\n\nexport { ImapChannelAdapter }\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { resolveSafeHostAddress } from "./host-pinning.js";
|
|
2
2
|
import { assertTransportAllowed } from "./transport.js";
|
|
3
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
4
|
+
const logger = createLogger("channel_imap");
|
|
3
5
|
class NodemailerClient {
|
|
4
6
|
async verify(options) {
|
|
5
7
|
const { transporter } = await this.createTransporter(options);
|
|
@@ -50,10 +52,7 @@ class NodemailerClient {
|
|
|
50
52
|
}
|
|
51
53
|
} catch (composeError) {
|
|
52
54
|
raw = Buffer.alloc(0);
|
|
53
|
-
|
|
54
|
-
"[internal] channel_imap: failed to build RFC2822 bytes for Sent-folder append:",
|
|
55
|
-
composeError instanceof Error ? composeError.message : composeError
|
|
56
|
-
);
|
|
55
|
+
logger.warn("failed to build RFC2822 bytes for Sent-folder append", { err: composeError });
|
|
57
56
|
}
|
|
58
57
|
}
|
|
59
58
|
const info = await transporter.sendMail(mailOptions);
|
|
@@ -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'\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
|
|
5
|
-
"mappings": "AACA,SAAS,8BAA8B;AACvC,SAAS,8BAA8B;
|
|
4
|
+
"sourcesContent": ["import type { ImapCredentials } from './credentials'\nimport { resolveSafeHostAddress } from './host-pinning'\nimport { assertTransportAllowed } from './transport'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('channel_imap')\n\n/**\n * Outbound SMTP client wrapper. Same trade-offs as `imap-client.ts`: we wrap\n * `nodemailer` behind a tiny interface so tests can swap in a mock and the\n * adapter doesn't import SDK types directly.\n */\n\nexport interface SmtpConnectionOptions {\n host: string\n port: number\n user: string\n pass: string\n transport: 'tls' | 'starttls' | 'none'\n timeoutMs?: number\n}\n\nexport interface SmtpMessage {\n from: string\n to: string[]\n cc?: string[]\n bcc?: string[]\n subject?: string\n text?: string\n html?: string\n /** RFC2822 Message-ID; if omitted nodemailer generates one. */\n messageId?: string\n /** RFC2822 In-Reply-To (single value). */\n inReplyTo?: string\n /** RFC2822 References (whitespace-delimited list). */\n references?: string[]\n attachments?: Array<{\n filename: string\n content: Buffer\n contentType?: string\n cid?: string\n inline?: boolean\n }>\n headers?: Record<string, string>\n}\n\nexport interface SmtpSendResult {\n /** Effective Message-ID. */\n messageId: string\n /** Raw RFC2822 message buffer (used for Sent-folder append). */\n raw: Buffer\n /** Provider response string. */\n response?: string\n}\n\nexport interface SmtpClient {\n verify(options: SmtpConnectionOptions): Promise<void>\n send(options: SmtpConnectionOptions, message: SmtpMessage): Promise<SmtpSendResult>\n}\n\nclass NodemailerClient implements SmtpClient {\n async verify(options: SmtpConnectionOptions): Promise<void> {\n const { transporter } = await this.createTransporter(options)\n try {\n await transporter.verify()\n } finally {\n // Mirror send(): close on every path so a failed verify (wrong password,\n // unreachable host \u2014 the common case) does not leak the socket pool.\n transporter.close()\n }\n }\n\n async send(options: SmtpConnectionOptions, message: SmtpMessage): Promise<SmtpSendResult> {\n const { transporter, MailComposer } = await this.createTransporter(options)\n try {\n const mailOptions: Record<string, unknown> = {\n from: message.from,\n to: message.to,\n cc: message.cc,\n bcc: message.bcc,\n subject: message.subject,\n text: message.text,\n html: message.html,\n messageId: message.messageId,\n inReplyTo: message.inReplyTo,\n references: message.references,\n attachments: message.attachments?.map((a) => ({\n filename: a.filename,\n content: a.content,\n contentType: a.contentType,\n cid: a.cid,\n contentDisposition: a.inline ? 'inline' : 'attachment',\n })),\n headers: message.headers,\n }\n\n // Build the RFC2822 bytes ourselves via MailComposer so we can capture\n // them for the Sent-folder append (review H1, 2026-05-26).\n // nodemailer's `transporter.sendMail` info object does NOT contain `raw`\n // unless you configure a streamTransport, so naively reading\n // `info.raw` produces a 0-byte buffer and the Sent-folder append uploads\n // a corrupt message.\n let raw: Buffer = Buffer.alloc(0)\n let composedMessageId = message.messageId\n if (typeof MailComposer === 'function') {\n try {\n const composed = new MailComposer(mailOptions) as unknown as {\n compile: () => {\n build: (callback: (err: Error | null, output: Buffer) => void) => void\n messageId?: () => string | undefined\n }\n }\n const compiled = composed.compile()\n raw = await new Promise<Buffer>((resolve, reject) => {\n compiled.build((err, output) => {\n if (err) reject(err)\n else resolve(output)\n })\n })\n const messageIdFn = compiled.messageId\n if (typeof messageIdFn === 'function') {\n composedMessageId = messageIdFn.call(compiled) ?? composedMessageId\n }\n } catch (composeError) {\n // MailComposer build failed: the send below still delivers the mail, but we\n // cannot capture the RFC2822 bytes, so the caller skips the Sent-folder append.\n // Log so operators can diagnose missing Sent archival.\n raw = Buffer.alloc(0)\n logger.warn('failed to build RFC2822 bytes for Sent-folder append', { err: composeError })\n }\n }\n\n const info = (await transporter.sendMail(mailOptions)) as {\n messageId?: string\n envelope?: { messageId?: string }\n response?: string\n }\n const id = info.messageId ?? composedMessageId ?? info.envelope?.messageId\n if (!id) throw new Error('[internal] SMTP server did not return a Message-ID')\n return { messageId: id, raw, response: info.response }\n } finally {\n transporter.close()\n }\n }\n\n private async createTransporter(\n options: SmtpConnectionOptions,\n ): Promise<{\n transporter: NodemailerTransporter\n MailComposer: (new (mail: Record<string, unknown>) => unknown) | undefined\n }> {\n const mod = (await import('nodemailer')) as unknown as {\n default?: {\n createTransport: (opts: Record<string, unknown>) => NodemailerTransporter\n MailComposer?: new (mail: Record<string, unknown>) => unknown\n }\n createTransport?: (opts: Record<string, unknown>) => NodemailerTransporter\n MailComposer?: new (mail: Record<string, unknown>) => unknown\n }\n const createTransport = mod.createTransport ?? mod.default?.createTransport\n if (typeof createTransport !== 'function') {\n throw new Error('nodemailer.createTransport is unavailable')\n }\n const MailComposer = mod.MailComposer ?? mod.default?.MailComposer\n // Resolve + pin the SMTP host to a validated public IP at connect time\n // (DNS-rebinding-safe), keeping the hostname as the TLS servername for SNI +\n // certificate hostname verification.\n const pinned = await resolveSafeHostAddress(options.host)\n const transporter = createTransport({\n host: pinned.host,\n port: options.port,\n secure: options.transport === 'tls',\n requireTLS: options.transport === 'starttls',\n auth: { user: options.user, pass: options.pass },\n connectionTimeout: options.timeoutMs ?? 10_000,\n // Reject downgrade attacks: only allow cleartext when the operator\n // explicitly opts into `transport: 'none'`. Even then, refuse to skip\n // certificate verification on STARTTLS / TLS.\n tls:\n options.transport === 'none'\n ? undefined\n : { rejectUnauthorized: true, ...(pinned.servername ? { servername: pinned.servername } : {}) },\n })\n return { transporter, MailComposer }\n }\n}\n\ninterface NodemailerTransporter {\n verify(): Promise<true>\n sendMail(options: Record<string, unknown>): Promise<unknown>\n close(): void\n}\n\nlet cachedClient: SmtpClient | null = null\n\nexport function getSmtpClient(): SmtpClient {\n if (!cachedClient) cachedClient = new NodemailerClient()\n return cachedClient\n}\n\nexport function setSmtpClient(client: SmtpClient | null): void {\n cachedClient = client\n}\n\nexport function credentialsToSmtpConnection(credentials: ImapCredentials): SmtpConnectionOptions {\n assertTransportAllowed(credentials)\n return {\n host: credentials.smtpHost,\n port: Number(credentials.smtpPort),\n user: credentials.smtpUser,\n pass: credentials.smtpPassword,\n transport: credentials.smtpTls,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -8,6 +8,8 @@ import {
|
|
|
8
8
|
getSmtpClient
|
|
9
9
|
} from "./smtp-client.js";
|
|
10
10
|
import { INSECURE_TRANSPORT_MESSAGE, isInsecureTransportAllowed } from "./transport.js";
|
|
11
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
12
|
+
const logger = createLogger("channel_imap");
|
|
11
13
|
async function validateImapCredentials(rawCredentials) {
|
|
12
14
|
const parsed = imapCredentialsSchema.safeParse(rawCredentials);
|
|
13
15
|
if (!parsed.success) {
|
|
@@ -54,7 +56,7 @@ async function validateImapCredentials(rawCredentials) {
|
|
|
54
56
|
}
|
|
55
57
|
function classifyAuthError(error, fallback) {
|
|
56
58
|
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
57
|
-
|
|
59
|
+
logger.warn("Credential validation failed", { message });
|
|
58
60
|
if (/auth|login|credentials|535|454|530/i.test(message)) {
|
|
59
61
|
return "Authentication rejected by the server. Check the username and password.";
|
|
60
62
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/channel_imap/lib/validate-credentials.ts"],
|
|
4
|
-
"sourcesContent": ["import type { ValidateCredentialsResult } from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport { imapCredentialsSchema } from './credentials'\nimport {\n credentialsToConnection,\n getImapClient,\n} from './imap-client'\nimport {\n credentialsToSmtpConnection,\n getSmtpClient,\n} from './smtp-client'\nimport { INSECURE_TRANSPORT_MESSAGE, isInsecureTransportAllowed } from './transport'\n\n/**\n * Validate IMAP+SMTP credentials by attempting a live LOGIN on both servers.\n *\n * Strategy:\n * 1. Zod-parse the credential payload \u2014 returns shape errors first.\n * 2. Open IMAP, capture capabilities, log out.\n * 3. Run SMTP `verify` (extends EHLO, optional STARTTLS, AUTH LOGIN ping).\n *\n * Returns `{ ok: false, errors }` with field-level messages so the hub can pass\n * them straight to `createCrudFormError` and the CrudForm inline-highlights the\n * offending input. Returns `{ ok: true }` only when both servers accept the login.\n */\n\nexport async function validateImapCredentials(\n rawCredentials: unknown,\n): Promise<ValidateCredentialsResult> {\n const parsed = imapCredentialsSchema.safeParse(rawCredentials)\n if (!parsed.success) {\n const errors: Record<string, string> = {}\n for (const issue of parsed.error.issues) {\n const path = issue.path[0]\n if (typeof path !== 'string') continue\n // First error wins per field \u2014 CrudForm only renders one per field anyway.\n if (!errors[path]) errors[path] = issue.message\n }\n return { ok: false, errors }\n }\n\n const credentials = parsed.data\n\n // Reject cleartext transport by default. The shared `transport` helper is the\n // single source of truth for the policy (and enforces it again at connection\n // build time for every op); here we surface it as field-level errors so the\n // connect form can inline-highlight the offending TLS selector without\n // touching the network.\n if (!isInsecureTransportAllowed()) {\n const insecureTransportErrors: Record<string, string> = {}\n if (credentials.imapTls === 'none') insecureTransportErrors.imapTls = INSECURE_TRANSPORT_MESSAGE\n if (credentials.smtpTls === 'none') insecureTransportErrors.smtpTls = INSECURE_TRANSPORT_MESSAGE\n if (Object.keys(insecureTransportErrors).length > 0) {\n return { ok: false, errors: insecureTransportErrors }\n }\n }\n\n const imap = getImapClient()\n const smtp = getSmtpClient()\n\n try {\n await imap.connectAndValidate(credentialsToConnection(credentials))\n } catch (error) {\n return {\n ok: false,\n errors: {\n imapPassword: classifyAuthError(error, 'IMAP login failed.'),\n },\n }\n }\n\n try {\n await smtp.verify(credentialsToSmtpConnection(credentials))\n } catch (error) {\n return {\n ok: false,\n errors: {\n smtpPassword: classifyAuthError(error, 'SMTP login failed.'),\n },\n }\n }\n\n return { ok: true }\n}\n\nfunction classifyAuthError(error: unknown, fallback: string): string {\n // Keep the coarse classification but never echo raw upstream server text\n // (banners, internal hostnames) back to the client. Log the full original\n // message server-side for diagnostics instead.\n const message = error instanceof Error ? error.message : String(error ?? '')\n
|
|
5
|
-
"mappings": "AACA,SAAS,6BAA6B;AACtC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,4BAA4B,kCAAkC;
|
|
4
|
+
"sourcesContent": ["import type { ValidateCredentialsResult } from '@open-mercato/core/modules/communication_channels/lib/adapter'\nimport { imapCredentialsSchema } from './credentials'\nimport {\n credentialsToConnection,\n getImapClient,\n} from './imap-client'\nimport {\n credentialsToSmtpConnection,\n getSmtpClient,\n} from './smtp-client'\nimport { INSECURE_TRANSPORT_MESSAGE, isInsecureTransportAllowed } from './transport'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('channel_imap')\n\n/**\n * Validate IMAP+SMTP credentials by attempting a live LOGIN on both servers.\n *\n * Strategy:\n * 1. Zod-parse the credential payload \u2014 returns shape errors first.\n * 2. Open IMAP, capture capabilities, log out.\n * 3. Run SMTP `verify` (extends EHLO, optional STARTTLS, AUTH LOGIN ping).\n *\n * Returns `{ ok: false, errors }` with field-level messages so the hub can pass\n * them straight to `createCrudFormError` and the CrudForm inline-highlights the\n * offending input. Returns `{ ok: true }` only when both servers accept the login.\n */\n\nexport async function validateImapCredentials(\n rawCredentials: unknown,\n): Promise<ValidateCredentialsResult> {\n const parsed = imapCredentialsSchema.safeParse(rawCredentials)\n if (!parsed.success) {\n const errors: Record<string, string> = {}\n for (const issue of parsed.error.issues) {\n const path = issue.path[0]\n if (typeof path !== 'string') continue\n // First error wins per field \u2014 CrudForm only renders one per field anyway.\n if (!errors[path]) errors[path] = issue.message\n }\n return { ok: false, errors }\n }\n\n const credentials = parsed.data\n\n // Reject cleartext transport by default. The shared `transport` helper is the\n // single source of truth for the policy (and enforces it again at connection\n // build time for every op); here we surface it as field-level errors so the\n // connect form can inline-highlight the offending TLS selector without\n // touching the network.\n if (!isInsecureTransportAllowed()) {\n const insecureTransportErrors: Record<string, string> = {}\n if (credentials.imapTls === 'none') insecureTransportErrors.imapTls = INSECURE_TRANSPORT_MESSAGE\n if (credentials.smtpTls === 'none') insecureTransportErrors.smtpTls = INSECURE_TRANSPORT_MESSAGE\n if (Object.keys(insecureTransportErrors).length > 0) {\n return { ok: false, errors: insecureTransportErrors }\n }\n }\n\n const imap = getImapClient()\n const smtp = getSmtpClient()\n\n try {\n await imap.connectAndValidate(credentialsToConnection(credentials))\n } catch (error) {\n return {\n ok: false,\n errors: {\n imapPassword: classifyAuthError(error, 'IMAP login failed.'),\n },\n }\n }\n\n try {\n await smtp.verify(credentialsToSmtpConnection(credentials))\n } catch (error) {\n return {\n ok: false,\n errors: {\n smtpPassword: classifyAuthError(error, 'SMTP login failed.'),\n },\n }\n }\n\n return { ok: true }\n}\n\nfunction classifyAuthError(error: unknown, fallback: string): string {\n // Keep the coarse classification but never echo raw upstream server text\n // (banners, internal hostnames) back to the client. Log the full original\n // message server-side for diagnostics instead.\n const message = error instanceof Error ? error.message : String(error ?? '')\n logger.warn('Credential validation failed', { message })\n if (/auth|login|credentials|535|454|530/i.test(message)) {\n return 'Authentication rejected by the server. Check the username and password.'\n }\n if (/timeout|ETIMEDOUT|ECONNREFUSED|ENOTFOUND|EAI_AGAIN/i.test(message)) {\n return 'Could not reach the server. Check the host and port.'\n }\n return fallback\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,6BAA6B;AACtC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,4BAA4B,kCAAkC;AACvE,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,cAAc;AAe1C,eAAsB,wBACpB,gBACoC;AACpC,QAAM,SAAS,sBAAsB,UAAU,cAAc;AAC7D,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,SAAiC,CAAC;AACxC,eAAW,SAAS,OAAO,MAAM,QAAQ;AACvC,YAAM,OAAO,MAAM,KAAK,CAAC;AACzB,UAAI,OAAO,SAAS,SAAU;AAE9B,UAAI,CAAC,OAAO,IAAI,EAAG,QAAO,IAAI,IAAI,MAAM;AAAA,IAC1C;AACA,WAAO,EAAE,IAAI,OAAO,OAAO;AAAA,EAC7B;AAEA,QAAM,cAAc,OAAO;AAO3B,MAAI,CAAC,2BAA2B,GAAG;AACjC,UAAM,0BAAkD,CAAC;AACzD,QAAI,YAAY,YAAY,OAAQ,yBAAwB,UAAU;AACtE,QAAI,YAAY,YAAY,OAAQ,yBAAwB,UAAU;AACtE,QAAI,OAAO,KAAK,uBAAuB,EAAE,SAAS,GAAG;AACnD,aAAO,EAAE,IAAI,OAAO,QAAQ,wBAAwB;AAAA,IACtD;AAAA,EACF;AAEA,QAAM,OAAO,cAAc;AAC3B,QAAM,OAAO,cAAc;AAE3B,MAAI;AACF,UAAM,KAAK,mBAAmB,wBAAwB,WAAW,CAAC;AAAA,EACpE,SAAS,OAAO;AACd,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,QAAQ;AAAA,QACN,cAAc,kBAAkB,OAAO,oBAAoB;AAAA,MAC7D;AAAA,IACF;AAAA,EACF;AAEA,MAAI;AACF,UAAM,KAAK,OAAO,4BAA4B,WAAW,CAAC;AAAA,EAC5D,SAAS,OAAO;AACd,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,QAAQ;AAAA,QACN,cAAc,kBAAkB,OAAO,oBAAoB;AAAA,MAC7D;AAAA,IACF;AAAA,EACF;AAEA,SAAO,EAAE,IAAI,KAAK;AACpB;AAEA,SAAS,kBAAkB,OAAgB,UAA0B;AAInE,QAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,SAAS,EAAE;AAC3E,SAAO,KAAK,gCAAgC,EAAE,QAAQ,CAAC;AACvD,MAAI,sCAAsC,KAAK,OAAO,GAAG;AACvD,WAAO;AAAA,EACT;AACA,MAAI,sDAAsD,KAAK,OAAO,GAAG;AACvE,WAAO;AAAA,EACT;AACA,SAAO;AACT;",
|
|
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.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6471.1.9ab5bdd79a",
|
|
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.6.6-develop.
|
|
66
|
-
"@open-mercato/queue": "0.6.6-develop.
|
|
67
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
65
|
+
"@open-mercato/core": "0.6.6-develop.6471.1.9ab5bdd79a",
|
|
66
|
+
"@open-mercato/queue": "0.6.6-develop.6471.1.9ab5bdd79a",
|
|
67
|
+
"@open-mercato/ui": "0.6.6-develop.6471.1.9ab5bdd79a",
|
|
68
68
|
"@types/mailparser": "^3.4.5",
|
|
69
69
|
"@types/nodemailer": "^8.0.1",
|
|
70
70
|
"imapflow": "^1.4.3",
|
|
@@ -73,12 +73,12 @@
|
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
76
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
76
|
+
"@open-mercato/shared": "0.6.6-develop.6471.1.9ab5bdd79a",
|
|
77
77
|
"react": "^19.0.0",
|
|
78
78
|
"react-dom": "^19.0.0"
|
|
79
79
|
},
|
|
80
80
|
"devDependencies": {
|
|
81
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
81
|
+
"@open-mercato/shared": "0.6.6-develop.6471.1.9ab5bdd79a",
|
|
82
82
|
"@types/jest": "^30.0.0",
|
|
83
83
|
"@types/react": "^19.2.17",
|
|
84
84
|
"@types/react-dom": "^19.2.3",
|
|
@@ -33,6 +33,9 @@ import { normalizeInboundImapMessage } from './normalize-inbound'
|
|
|
33
33
|
import { validateImapCredentials } from './validate-credentials'
|
|
34
34
|
import { emailResolveContact } from '@open-mercato/core/modules/communication_channels/lib/email-contact'
|
|
35
35
|
import { decodeCursor, encodeCursor } from '@open-mercato/core/modules/communication_channels/lib/email-mime'
|
|
36
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
37
|
+
|
|
38
|
+
const logger = createLogger('channel_imap')
|
|
36
39
|
|
|
37
40
|
/**
|
|
38
41
|
* IMAP+SMTP `ChannelAdapter`. Inbound is polling-driven (`realtimePush: false`),
|
|
@@ -117,10 +120,7 @@ class ImapChannelAdapter implements ChannelAdapter {
|
|
|
117
120
|
// Sent mailbox name is provider-specific (localized, or "[Gmail]/Sent Mail").
|
|
118
121
|
// Log so operators can diagnose missing Sent-folder archival rather than
|
|
119
122
|
// failing the send.
|
|
120
|
-
|
|
121
|
-
'[internal] channel_imap: failed to append outbound message to Sent folder:',
|
|
122
|
-
appendError instanceof Error ? appendError.message : appendError,
|
|
123
|
-
)
|
|
123
|
+
logger.warn('failed to append outbound message to Sent folder', { err: appendError })
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
return {
|
|
@@ -184,11 +184,10 @@ class ImapChannelAdapter implements ChannelAdapter {
|
|
|
184
184
|
folderState.uidValidity !== undefined &&
|
|
185
185
|
folderState.uidValidity !== previousUidValidity
|
|
186
186
|
if (uidValidityMismatch) {
|
|
187
|
-
|
|
188
|
-
'[channel-imap] UIDVALIDITY changed for INBOX (was %s, now %s) — discarding cursor and re-bootstrapping',
|
|
187
|
+
logger.warn('UIDVALIDITY changed for INBOX — discarding cursor and re-bootstrapping', {
|
|
189
188
|
previousUidValidity,
|
|
190
|
-
folderState.uidValidity,
|
|
191
|
-
)
|
|
189
|
+
uidValidity: folderState.uidValidity,
|
|
190
|
+
})
|
|
192
191
|
}
|
|
193
192
|
const needsBootstrap =
|
|
194
193
|
uidValidityMismatch || previousUidNext === undefined || previousUidNext === null
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ImapCredentials } from './credentials'
|
|
2
2
|
import { resolveSafeHostAddress } from './host-pinning'
|
|
3
3
|
import { assertTransportAllowed } from './transport'
|
|
4
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
5
|
+
|
|
6
|
+
const logger = createLogger('channel_imap')
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* Outbound SMTP client wrapper. Same trade-offs as `imap-client.ts`: we wrap
|
|
@@ -123,10 +126,7 @@ class NodemailerClient implements SmtpClient {
|
|
|
123
126
|
// cannot capture the RFC2822 bytes, so the caller skips the Sent-folder append.
|
|
124
127
|
// Log so operators can diagnose missing Sent archival.
|
|
125
128
|
raw = Buffer.alloc(0)
|
|
126
|
-
|
|
127
|
-
'[internal] channel_imap: failed to build RFC2822 bytes for Sent-folder append:',
|
|
128
|
-
composeError instanceof Error ? composeError.message : composeError,
|
|
129
|
-
)
|
|
129
|
+
logger.warn('failed to build RFC2822 bytes for Sent-folder append', { err: composeError })
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
getSmtpClient,
|
|
10
10
|
} from './smtp-client'
|
|
11
11
|
import { INSECURE_TRANSPORT_MESSAGE, isInsecureTransportAllowed } from './transport'
|
|
12
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
13
|
+
|
|
14
|
+
const logger = createLogger('channel_imap')
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Validate IMAP+SMTP credentials by attempting a live LOGIN on both servers.
|
|
@@ -87,7 +90,7 @@ function classifyAuthError(error: unknown, fallback: string): string {
|
|
|
87
90
|
// (banners, internal hostnames) back to the client. Log the full original
|
|
88
91
|
// message server-side for diagnostics instead.
|
|
89
92
|
const message = error instanceof Error ? error.message : String(error ?? '')
|
|
90
|
-
|
|
93
|
+
logger.warn('Credential validation failed', { message })
|
|
91
94
|
if (/auth|login|credentials|535|454|530/i.test(message)) {
|
|
92
95
|
return 'Authentication rejected by the server. Check the username and password.'
|
|
93
96
|
}
|