@open-mercato/channel-imap 0.6.4

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.
Files changed (114) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +56 -0
  3. package/build.mjs +7 -0
  4. package/dist/index.js +5 -0
  5. package/dist/index.js.map +7 -0
  6. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.js +62 -0
  7. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.js.map +7 -0
  8. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-002.spec.js +19 -0
  9. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-002.spec.js.map +7 -0
  10. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-003.spec.js +16 -0
  11. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-003.spec.js.map +7 -0
  12. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-021.spec.js +26 -0
  13. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-021.spec.js.map +7 -0
  14. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-022.spec.js +27 -0
  15. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-022.spec.js.map +7 -0
  16. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-023.spec.js +15 -0
  17. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-023.spec.js.map +7 -0
  18. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-024.spec.js +15 -0
  19. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-024.spec.js.map +7 -0
  20. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-025.spec.js +6 -0
  21. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-025.spec.js.map +7 -0
  22. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-026.spec.js +6 -0
  23. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-026.spec.js.map +7 -0
  24. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-027.spec.js +6 -0
  25. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-027.spec.js.map +7 -0
  26. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-028.spec.js +6 -0
  27. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-028.spec.js.map +7 -0
  28. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-029.spec.js +48 -0
  29. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-029.spec.js.map +7 -0
  30. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-030.spec.js +6 -0
  31. package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-030.spec.js.map +7 -0
  32. package/dist/modules/channel_imap/acl.js +10 -0
  33. package/dist/modules/channel_imap/acl.js.map +7 -0
  34. package/dist/modules/channel_imap/di.js +23 -0
  35. package/dist/modules/channel_imap/di.js.map +7 -0
  36. package/dist/modules/channel_imap/index.js +9 -0
  37. package/dist/modules/channel_imap/index.js.map +7 -0
  38. package/dist/modules/channel_imap/integration.js +135 -0
  39. package/dist/modules/channel_imap/integration.js.map +7 -0
  40. package/dist/modules/channel_imap/lib/adapter.js +291 -0
  41. package/dist/modules/channel_imap/lib/adapter.js.map +7 -0
  42. package/dist/modules/channel_imap/lib/capabilities.js +8 -0
  43. package/dist/modules/channel_imap/lib/capabilities.js.map +7 -0
  44. package/dist/modules/channel_imap/lib/convert-outbound.js +54 -0
  45. package/dist/modules/channel_imap/lib/convert-outbound.js.map +7 -0
  46. package/dist/modules/channel_imap/lib/credentials.js +104 -0
  47. package/dist/modules/channel_imap/lib/credentials.js.map +7 -0
  48. package/dist/modules/channel_imap/lib/health.js +39 -0
  49. package/dist/modules/channel_imap/lib/health.js.map +7 -0
  50. package/dist/modules/channel_imap/lib/host-pinning.js +34 -0
  51. package/dist/modules/channel_imap/lib/host-pinning.js.map +7 -0
  52. package/dist/modules/channel_imap/lib/imap-client.js +210 -0
  53. package/dist/modules/channel_imap/lib/imap-client.js.map +7 -0
  54. package/dist/modules/channel_imap/lib/normalize-inbound.js +19 -0
  55. package/dist/modules/channel_imap/lib/normalize-inbound.js.map +7 -0
  56. package/dist/modules/channel_imap/lib/smtp-client.js +113 -0
  57. package/dist/modules/channel_imap/lib/smtp-client.js.map +7 -0
  58. package/dist/modules/channel_imap/lib/transport.js +17 -0
  59. package/dist/modules/channel_imap/lib/transport.js.map +7 -0
  60. package/dist/modules/channel_imap/lib/validate-credentials.js +69 -0
  61. package/dist/modules/channel_imap/lib/validate-credentials.js.map +7 -0
  62. package/dist/modules/channel_imap/setup.js +25 -0
  63. package/dist/modules/channel_imap/setup.js.map +7 -0
  64. package/dist/modules/channel_imap/widgets/injection/connect/widget.client.js +337 -0
  65. package/dist/modules/channel_imap/widgets/injection/connect/widget.client.js.map +7 -0
  66. package/dist/modules/channel_imap/widgets/injection/connect/widget.js +17 -0
  67. package/dist/modules/channel_imap/widgets/injection/connect/widget.js.map +7 -0
  68. package/dist/modules/channel_imap/widgets/injection-table.js +14 -0
  69. package/dist/modules/channel_imap/widgets/injection-table.js.map +7 -0
  70. package/jest.config.cjs +34 -0
  71. package/package.json +99 -0
  72. package/src/index.ts +1 -0
  73. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.ts +80 -0
  74. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-002.spec.ts +28 -0
  75. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-003.spec.ts +23 -0
  76. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-021.spec.ts +40 -0
  77. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-022.spec.ts +38 -0
  78. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-023.spec.ts +31 -0
  79. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-024.spec.ts +27 -0
  80. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-025.spec.ts +23 -0
  81. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-026.spec.ts +18 -0
  82. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-027.spec.ts +18 -0
  83. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-028.spec.ts +19 -0
  84. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-029.spec.ts +72 -0
  85. package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-030.spec.ts +19 -0
  86. package/src/modules/channel_imap/acl.ts +6 -0
  87. package/src/modules/channel_imap/di.ts +26 -0
  88. package/src/modules/channel_imap/index.ts +6 -0
  89. package/src/modules/channel_imap/integration.ts +131 -0
  90. package/src/modules/channel_imap/lib/__tests__/adapter.test.ts +499 -0
  91. package/src/modules/channel_imap/lib/__tests__/convert-outbound.test.ts +73 -0
  92. package/src/modules/channel_imap/lib/__tests__/credentials.test.ts +154 -0
  93. package/src/modules/channel_imap/lib/__tests__/host-pinning.test.ts +68 -0
  94. package/src/modules/channel_imap/lib/__tests__/imap-client.test.ts +180 -0
  95. package/src/modules/channel_imap/lib/__tests__/normalize-inbound.test.ts +126 -0
  96. package/src/modules/channel_imap/lib/__tests__/transport.test.ts +68 -0
  97. package/src/modules/channel_imap/lib/__tests__/validate-credentials.test.ts +156 -0
  98. package/src/modules/channel_imap/lib/adapter.ts +451 -0
  99. package/src/modules/channel_imap/lib/capabilities.ts +16 -0
  100. package/src/modules/channel_imap/lib/convert-outbound.ts +79 -0
  101. package/src/modules/channel_imap/lib/credentials.ts +172 -0
  102. package/src/modules/channel_imap/lib/health.ts +70 -0
  103. package/src/modules/channel_imap/lib/host-pinning.ts +59 -0
  104. package/src/modules/channel_imap/lib/imap-client.ts +382 -0
  105. package/src/modules/channel_imap/lib/normalize-inbound.ts +47 -0
  106. package/src/modules/channel_imap/lib/smtp-client.ts +214 -0
  107. package/src/modules/channel_imap/lib/transport.ts +37 -0
  108. package/src/modules/channel_imap/lib/validate-credentials.ts +98 -0
  109. package/src/modules/channel_imap/setup.ts +34 -0
  110. package/src/modules/channel_imap/widgets/injection/connect/widget.client.tsx +359 -0
  111. package/src/modules/channel_imap/widgets/injection/connect/widget.ts +16 -0
  112. package/src/modules/channel_imap/widgets/injection-table.ts +12 -0
  113. package/tsconfig.json +9 -0
  114. package/watch.mjs +7 -0
@@ -0,0 +1,156 @@
1
+ import { setImapClient } from '../imap-client'
2
+ import { setSmtpClient } from '../smtp-client'
3
+ import { validateImapCredentials } from '../validate-credentials'
4
+
5
+ const validRaw = {
6
+ imapHost: 'imap.example.com',
7
+ imapPort: 993,
8
+ imapTls: 'tls',
9
+ imapUser: 'alice@example.com',
10
+ imapPassword: 'secret',
11
+ smtpHost: 'smtp.example.com',
12
+ smtpPort: 465,
13
+ smtpTls: 'tls',
14
+ smtpUser: 'alice@example.com',
15
+ smtpPassword: 'secret',
16
+ fromAddress: 'alice@example.com',
17
+ }
18
+
19
+ afterEach(() => {
20
+ setImapClient(null)
21
+ setSmtpClient(null)
22
+ delete process.env.OM_CHANNEL_IMAP_ALLOW_INSECURE_TRANSPORT
23
+ })
24
+
25
+ describe('validateImapCredentials', () => {
26
+ it('returns shape errors before attempting a network login', async () => {
27
+ let imapTouched = false
28
+ let smtpTouched = false
29
+ setImapClient({
30
+ connectAndValidate: async () => {
31
+ imapTouched = true
32
+ return { capabilities: [] }
33
+ },
34
+ selectInbox: async () => ({}),
35
+ fetchUidRange: async () => [],
36
+ appendSent: async () => undefined,
37
+ })
38
+ setSmtpClient({
39
+ verify: async () => {
40
+ smtpTouched = true
41
+ },
42
+ send: async () => ({ messageId: 'x', raw: Buffer.alloc(0) }),
43
+ })
44
+ const result = await validateImapCredentials({ ...validRaw, imapPort: 70_000 })
45
+ expect(result.ok).toBe(false)
46
+ if (!result.ok) {
47
+ expect(result.errors?.imapPort).toMatch(/IMAP port/i)
48
+ }
49
+ expect(imapTouched).toBe(false)
50
+ expect(smtpTouched).toBe(false)
51
+ })
52
+
53
+ it('returns field-level imap error when imap login fails', async () => {
54
+ setImapClient({
55
+ connectAndValidate: async () => {
56
+ throw new Error('535 5.7.8 Authentication credentials invalid')
57
+ },
58
+ selectInbox: async () => ({}),
59
+ fetchUidRange: async () => [],
60
+ appendSent: async () => undefined,
61
+ })
62
+ setSmtpClient({
63
+ verify: async () => undefined,
64
+ send: async () => ({ messageId: 'x', raw: Buffer.alloc(0) }),
65
+ })
66
+ const result = await validateImapCredentials(validRaw)
67
+ expect(result.ok).toBe(false)
68
+ if (!result.ok) {
69
+ expect(result.errors?.imapPassword).toMatch(/authentication/i)
70
+ expect(result.errors?.smtpPassword).toBeUndefined()
71
+ }
72
+ })
73
+
74
+ it('returns field-level smtp error when smtp verify fails', async () => {
75
+ setImapClient({
76
+ connectAndValidate: async () => ({ capabilities: [] }),
77
+ selectInbox: async () => ({}),
78
+ fetchUidRange: async () => [],
79
+ appendSent: async () => undefined,
80
+ })
81
+ setSmtpClient({
82
+ verify: async () => {
83
+ throw new Error('ECONNREFUSED smtp.example.com')
84
+ },
85
+ send: async () => ({ messageId: 'x', raw: Buffer.alloc(0) }),
86
+ })
87
+ const result = await validateImapCredentials(validRaw)
88
+ expect(result.ok).toBe(false)
89
+ if (!result.ok) {
90
+ expect(result.errors?.smtpPassword).toMatch(/could not reach/i)
91
+ }
92
+ })
93
+
94
+ it('returns ok when both servers accept the login', async () => {
95
+ setImapClient({
96
+ connectAndValidate: async () => ({ capabilities: ['IMAP4rev1'] }),
97
+ selectInbox: async () => ({}),
98
+ fetchUidRange: async () => [],
99
+ appendSent: async () => undefined,
100
+ })
101
+ setSmtpClient({
102
+ verify: async () => undefined,
103
+ send: async () => ({ messageId: 'x', raw: Buffer.alloc(0) }),
104
+ })
105
+ const result = await validateImapCredentials(validRaw)
106
+ expect(result.ok).toBe(true)
107
+ })
108
+
109
+ it("rejects 'none' transport by default without touching the network", async () => {
110
+ let imapTouched = false
111
+ setImapClient({
112
+ connectAndValidate: async () => {
113
+ imapTouched = true
114
+ return { capabilities: [] }
115
+ },
116
+ selectInbox: async () => ({}),
117
+ fetchUidRange: async () => [],
118
+ appendSent: async () => undefined,
119
+ })
120
+ setSmtpClient({
121
+ verify: async () => undefined,
122
+ send: async () => ({ messageId: 'x', raw: Buffer.alloc(0) }),
123
+ })
124
+ const result = await validateImapCredentials({
125
+ ...validRaw,
126
+ imapTls: 'none',
127
+ smtpTls: 'none',
128
+ })
129
+ expect(result.ok).toBe(false)
130
+ if (!result.ok) {
131
+ expect(result.errors?.imapTls).toMatch(/cleartext/i)
132
+ expect(result.errors?.smtpTls).toMatch(/cleartext/i)
133
+ }
134
+ expect(imapTouched).toBe(false)
135
+ })
136
+
137
+ it("allows 'none' transport when OM_CHANNEL_IMAP_ALLOW_INSECURE_TRANSPORT is truthy", async () => {
138
+ process.env.OM_CHANNEL_IMAP_ALLOW_INSECURE_TRANSPORT = 'true'
139
+ setImapClient({
140
+ connectAndValidate: async () => ({ capabilities: ['IMAP4rev1'] }),
141
+ selectInbox: async () => ({}),
142
+ fetchUidRange: async () => [],
143
+ appendSent: async () => undefined,
144
+ })
145
+ setSmtpClient({
146
+ verify: async () => undefined,
147
+ send: async () => ({ messageId: 'x', raw: Buffer.alloc(0) }),
148
+ })
149
+ const result = await validateImapCredentials({
150
+ ...validRaw,
151
+ imapTls: 'none',
152
+ smtpTls: 'none',
153
+ })
154
+ expect(result.ok).toBe(true)
155
+ })
156
+ })
@@ -0,0 +1,451 @@
1
+ import type {
2
+ ChannelAdapter,
3
+ ConvertOutboundInput,
4
+ ChannelNativeContent,
5
+ FetchHistoryInput,
6
+ GetMessageStatusInput,
7
+ HistoryPage,
8
+ ImportHistoryInput,
9
+ ImportHistoryPage,
10
+ InboundMessage,
11
+ MessageStatus,
12
+ NormalizedInboundMessage,
13
+ ResolveContactInput,
14
+ ContactHint,
15
+ SendMessageInput,
16
+ SendMessageResult,
17
+ ValidateCredentialsInput,
18
+ ValidateCredentialsResult,
19
+ VerifyWebhookInput,
20
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter'
21
+ import { imapCapabilities } from './capabilities'
22
+ import { imapCredentialsSchema, imapChannelStateSchema, type ImapCredentials, type ImapChannelState } from './credentials'
23
+ import {
24
+ credentialsToConnection,
25
+ getImapClient,
26
+ } from './imap-client'
27
+ import {
28
+ credentialsToSmtpConnection,
29
+ getSmtpClient,
30
+ } from './smtp-client'
31
+ import { convertOutboundForEmail } from './convert-outbound'
32
+ import { normalizeInboundImapMessage } from './normalize-inbound'
33
+ import { validateImapCredentials } from './validate-credentials'
34
+ import { emailResolveContact } from '@open-mercato/core/modules/communication_channels/lib/email-contact'
35
+ import { decodeCursor, encodeCursor } from '@open-mercato/core/modules/communication_channels/lib/email-mime'
36
+
37
+ /**
38
+ * IMAP+SMTP `ChannelAdapter`. Inbound is polling-driven (`realtimePush: false`),
39
+ * outbound is SMTP. Threading is RFC2822 (In-Reply-To / References).
40
+ *
41
+ * Why this adapter omits some methods:
42
+ * - `verifyWebhook` — IMAP has no webhook; we return a no-op event with
43
+ * `eventType: 'other'` so the hub's webhook route returns 202 if anyone
44
+ * ever POSTs at `/api/communication_channels/webhook/imap`.
45
+ * - `getStatus` — IMAP has no delivery-status concept beyond `\Seen`; we
46
+ * return `{ status: 'sent' }` as a best-effort placeholder.
47
+ * - No `sendReaction` / `editMessage` / `deleteMessage` — email doesn't
48
+ * support these capabilities.
49
+ */
50
+ class ImapChannelAdapter implements ChannelAdapter {
51
+ readonly providerKey = 'imap'
52
+ readonly channelType = 'email'
53
+ readonly capabilities = imapCapabilities
54
+
55
+ async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {
56
+ const credentials = parseCredentialsOrThrow(input.credentials)
57
+
58
+ // Reject attachments at the boundary BEFORE building the MIME body. The hub
59
+ // passes attachments as URL pointers; until the IMAP/SMTP adapter wires a
60
+ // fetcher (with size + content-type validation), inlining them is unsafe —
61
+ // a 0-byte attachment looks "delivered" but conveys nothing. Checking here
62
+ // (rather than after conversion) avoids wasted MIME-build work and surfaces
63
+ // the clearer "attachments unsupported" error even when recipients are also
64
+ // missing. Documented in review M2 (2026-05-26) and tracked as a follow-up.
65
+ if (Array.isArray(input.content.attachments) && input.content.attachments.length > 0) {
66
+ return {
67
+ externalMessageId: '',
68
+ status: 'failed',
69
+ error:
70
+ '[internal] IMAP/SMTP adapter does not yet support attachments. Send the message without attachments or use a provider that supports them (Gmail).',
71
+ }
72
+ }
73
+
74
+ let native: ChannelNativeContent
75
+ try {
76
+ native = await convertOutboundForEmail({
77
+ body: input.content.html ?? input.content.text ?? '',
78
+ bodyFormat: input.content.bodyFormat ?? (input.content.html ? 'html' : 'text'),
79
+ attachments: input.content.attachments,
80
+ channelMetadata: input.metadata,
81
+ })
82
+ } catch (error) {
83
+ const message = error instanceof Error ? error.message : 'Outbound conversion failed'
84
+ return { externalMessageId: '', status: 'failed', error: message }
85
+ }
86
+ const meta = (native.metadata ?? {}) as Record<string, unknown>
87
+ const to = Array.isArray(meta.to) ? (meta.to as string[]) : []
88
+ if (to.length === 0) {
89
+ return { externalMessageId: '', status: 'failed', error: '[internal] Email send requires at least one recipient' }
90
+ }
91
+
92
+ const smtp = getSmtpClient()
93
+ const result = await smtp.send(credentialsToSmtpConnection(credentials), {
94
+ from: credentials.fromAddress,
95
+ to,
96
+ cc: Array.isArray(meta.cc) ? (meta.cc as string[]) : undefined,
97
+ bcc: Array.isArray(meta.bcc) ? (meta.bcc as string[]) : undefined,
98
+ subject: typeof meta.subject === 'string' ? (meta.subject as string) : undefined,
99
+ text: native.content.text,
100
+ html: native.content.html,
101
+ inReplyTo: typeof meta.inReplyTo === 'string' ? (meta.inReplyTo as string) : undefined,
102
+ references: Array.isArray(meta.references) ? (meta.references as string[]) : undefined,
103
+ messageId: typeof meta.messageId === 'string' ? (meta.messageId as string) : undefined,
104
+ })
105
+
106
+ // Best-effort append to Sent — many servers auto-store via "Submission" but not all do.
107
+ const imap = getImapClient()
108
+ try {
109
+ // Skip when the RFC2822 bytes are empty (MailComposer build failed upstream):
110
+ // appending a 0-byte buffer would create a corrupt Sent-folder entry, and the
111
+ // send itself already succeeded.
112
+ if (result.raw.length > 0) {
113
+ await imap.appendSent(credentialsToConnection(credentials), result.raw)
114
+ }
115
+ } catch (appendError) {
116
+ // Best-effort: many servers auto-store sent mail via Submission, and the
117
+ // Sent mailbox name is provider-specific (localized, or "[Gmail]/Sent Mail").
118
+ // Log so operators can diagnose missing Sent-folder archival rather than
119
+ // failing the send.
120
+ console.warn(
121
+ '[internal] channel_imap: failed to append outbound message to Sent folder:',
122
+ appendError instanceof Error ? appendError.message : appendError,
123
+ )
124
+ }
125
+
126
+ return {
127
+ externalMessageId: result.messageId,
128
+ conversationId: input.conversationId,
129
+ status: 'sent',
130
+ metadata: { response: result.response },
131
+ }
132
+ }
133
+
134
+ async verifyWebhook(_input: VerifyWebhookInput): Promise<InboundMessage> {
135
+ return { raw: {}, eventType: 'other', metadata: { reason: 'imap-does-not-use-webhooks' } }
136
+ }
137
+
138
+ async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {
139
+ return { status: 'sent' }
140
+ }
141
+
142
+ async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {
143
+ return convertOutboundForEmail(input)
144
+ }
145
+
146
+ async normalizeInbound(raw: InboundMessage): Promise<NormalizedInboundMessage> {
147
+ const rawBuffer = pickRawMimeBuffer(raw)
148
+ const accountIdentifier = pickAccountIdentifier(raw)
149
+ const uid = pickUid(raw)
150
+ return normalizeInboundImapMessage({
151
+ rawMessage: rawBuffer,
152
+ uid,
153
+ accountIdentifier,
154
+ })
155
+ }
156
+
157
+ async validateCredentials(input: ValidateCredentialsInput): Promise<ValidateCredentialsResult> {
158
+ return validateImapCredentials(input.credentials)
159
+ }
160
+
161
+ async fetchHistory(input: FetchHistoryInput): Promise<HistoryPage> {
162
+ const credentials = parseCredentialsOrThrow(input.credentials)
163
+ const channelState = imapChannelStateSchema.parse(input.channelState ?? {}) satisfies ImapChannelState
164
+ const imap = getImapClient()
165
+ const connection = credentialsToConnection(credentials)
166
+
167
+ // Spec B § Bounded, cursor-driven IMAP inbound:
168
+ // - Bootstrap (no cursor): SELECT INBOX, persist UIDVALIDITY + UIDNEXT,
169
+ // return ZERO messages. Backlog import happens via the explicit
170
+ // `/import-history` endpoint, not via the silent connect flow.
171
+ // - Incremental (cursor exists): UID FETCH `previousUidNext:*`, capped
172
+ // at HARD_CAP = 200. If more available, set `hasMore: true` so the
173
+ // hub re-enqueues immediately and drains the backlog.
174
+ // - UIDVALIDITY mismatch: discard cursor and treat as bootstrap (the
175
+ // mailbox was recreated or renamed; we cannot trust the prior cursor).
176
+ const folderState = await imap.selectInbox(connection)
177
+ const previousUidValidity = toNumberOrUndefined(channelState.uidValidity)
178
+ const previousUidNext = toNumberOrUndefined(channelState.uidNext)
179
+ const serverUidNext = toNumberOrUndefined(folderState.uidNext)
180
+ const HARD_CAP = clampHardCap(input.limit)
181
+
182
+ const uidValidityMismatch =
183
+ previousUidValidity !== undefined &&
184
+ folderState.uidValidity !== undefined &&
185
+ folderState.uidValidity !== previousUidValidity
186
+ if (uidValidityMismatch) {
187
+ console.warn(
188
+ '[channel-imap] UIDVALIDITY changed for INBOX (was %s, now %s) — discarding cursor and re-bootstrapping',
189
+ previousUidValidity,
190
+ folderState.uidValidity,
191
+ )
192
+ }
193
+ const needsBootstrap =
194
+ uidValidityMismatch || previousUidNext === undefined || previousUidNext === null
195
+
196
+ let fetched: { uid: number; rawBody: Buffer; internalDate?: Date; flags?: string[] }[]
197
+ let hasMore = false
198
+ if (needsBootstrap) {
199
+ // ── Bootstrap: persist cursor only, fetch zero messages ─────────────
200
+ // Spec B § Bootstrap. The "1M inbox" failure mode is fixed by
201
+ // construction: a fresh user sees zero history until they explicitly
202
+ // request `/import-history`. Set `hasMore: false` so the poll worker
203
+ // does NOT immediately re-enqueue.
204
+ fetched = []
205
+ hasMore = false
206
+ } else if (previousUidNext !== undefined && serverUidNext !== undefined && previousUidNext >= serverUidNext) {
207
+ // ── Idle: UIDNEXT did not advance, so there is no new mail ──────────
208
+ // Skip the FETCH entirely. IMAP `<n>:*` always matches at least the
209
+ // highest existing UID, so an idle mailbox would otherwise re-fetch and
210
+ // re-normalize one already-ingested message every tick. The cursor is
211
+ // retained downstream (an empty fetch does not advance it).
212
+ fetched = []
213
+ hasMore = false
214
+ } else {
215
+ // ── Incremental: UID FETCH previousUidNext:* up to HARD_CAP ─────────
216
+ // On a mature mailbox this is typically 0-N UIDs. The HARD_CAP bounds
217
+ // the per-poll wall-clock + DB transaction size; if more remain, the
218
+ // hub re-enqueues us immediately via `hasMore: true`.
219
+ const range = `${previousUidNext}:*`
220
+ // Fetch up to HARD_CAP + 1 so we can detect whether more remain
221
+ // without paying for an extra round-trip later.
222
+ const probeLimit = HARD_CAP + 1
223
+ const raw = await imap.fetchUidRange(connection, range, { limit: probeLimit })
224
+ if (raw.length > HARD_CAP) {
225
+ fetched = raw.slice(0, HARD_CAP)
226
+ hasMore = true
227
+ } else {
228
+ fetched = raw
229
+ hasMore = false
230
+ }
231
+ }
232
+
233
+ const messages: NormalizedInboundMessage[] = []
234
+ for (const item of fetched) {
235
+ const normalized = await normalizeInboundImapMessage({
236
+ rawMessage: item.rawBody,
237
+ uid: item.uid,
238
+ accountIdentifier: credentials.fromAddress,
239
+ fallbackDate: item.internalDate,
240
+ })
241
+ messages.push(normalized)
242
+ }
243
+
244
+ // Cursor advancement contract:
245
+ // - Bootstrap: persist the server's current UIDNEXT so the next poll
246
+ // becomes incremental from this point onward (intentionally skips the
247
+ // pre-existing backlog; use `/import-history` to pull it).
248
+ // - Incremental: persist `highestFetchedUid + 1` — NEVER the server's
249
+ // UIDNEXT. When `fetched` is empty we retain `previousUidNext` so the
250
+ // next poll resumes from the same point.
251
+ const advancedUidNext = (() => {
252
+ if (needsBootstrap) return serverUidNext
253
+ if (fetched.length === 0) return previousUidNext
254
+ const highest = fetched.reduce((max, item) => (item.uid > max ? item.uid : max), 0)
255
+ // Anchor the cursor to the highest UID we ACTUALLY fetched — never to the
256
+ // server's UIDNEXT. Providers like Gmail report a UIDNEXT that runs ahead
257
+ // of the highest message currently in the folder (UID gaps from
258
+ // labels/threads, or a message materialising into INBOX at a UID below
259
+ // UIDNEXT). Jumping the cursor to `serverUidNext` then steps over any
260
+ // INBOX message that sits below it: that message lands permanently below
261
+ // the cursor and is never fetched again — the bug that silently dropped
262
+ // inbound replies (UID 61979 skipped while cursor jumped to 61981).
263
+ // `highest + 1` guarantees we never step over an unfetched message; if the
264
+ // server's UIDNEXT is higher, the next poll just re-scans `highest+1:*`
265
+ // (idempotent — the hub dedups on (channel_id, external_message_id)).
266
+ return highest + 1
267
+ })()
268
+ const nextChannelState: ImapChannelState = {
269
+ uidValidity: folderState.uidValidity ?? previousUidValidity,
270
+ uidNext: advancedUidNext,
271
+ lastFolder: 'INBOX',
272
+ }
273
+
274
+ // The hub's polling worker re-reads cursor through `fetchHistory`. We embed the
275
+ // next-poll state in `nextCursor` (base64-encoded JSON) so workers can persist
276
+ // it onto `CommunicationChannel.channelState` without depending on a hub-specific
277
+ // contract beyond the existing `HistoryPage` shape.
278
+ const nextCursor = encodeCursor(nextChannelState)
279
+ return { messages, nextCursor, hasMore }
280
+ }
281
+
282
+ async importHistory(input: ImportHistoryInput): Promise<ImportHistoryPage> {
283
+ const credentials = parseCredentialsOrThrow(input.credentials)
284
+ const connection = credentialsToConnection(credentials)
285
+ const imap = getImapClient()
286
+
287
+ const sinceDaysRaw = Number.isFinite(input.sinceDays) ? Math.trunc(input.sinceDays) : 30
288
+ const sinceDays = Math.max(1, Math.min(365, sinceDaysRaw))
289
+ const sinceDate = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000)
290
+
291
+ const maxMessagesRaw = Number.isFinite(input.maxMessages) ? Math.trunc(input.maxMessages as number) : 1000
292
+ const maxMessages = Math.max(1, Math.min(5000, maxMessagesRaw))
293
+
294
+ const PAGE_SIZE = clampHardCap(undefined)
295
+
296
+ // Resume previous page or perform initial SEARCH on first call. The cursor
297
+ // encodes the full remaining UID list discovered server-side so subsequent
298
+ // pages don't re-issue SEARCH (which on large mailboxes is expensive).
299
+ let allUids: number[]
300
+ let remainingUids: number[]
301
+ let collectedSoFar: number
302
+ let totalCandidates: number | undefined
303
+ const cursor = decodeImportCursor(input.cursor)
304
+ if (cursor) {
305
+ remainingUids = cursor.remaining
306
+ collectedSoFar = cursor.collected
307
+ totalCandidates = cursor.total
308
+ allUids = cursor.remaining
309
+ } else {
310
+ // FROM-chunking: SEARCH with very long OR chains can blow imapflow's
311
+ // tag-buffer; chunk to ≤30 senders and union the results. When
312
+ // contactEmails is empty we issue a single SINCE-only search.
313
+ const senders = (input.contactEmails ?? []).filter((s): s is string => typeof s === 'string' && s.includes('@'))
314
+ const uidSet = new Set<number>()
315
+ if (senders.length === 0) {
316
+ const uids = await imap.searchUidsByFromAndSince(connection, { sinceDate })
317
+ for (const uid of uids) uidSet.add(uid)
318
+ } else {
319
+ const CHUNK_SIZE = 30
320
+ for (let i = 0; i < senders.length; i += CHUNK_SIZE) {
321
+ const chunk = senders.slice(i, i + CHUNK_SIZE)
322
+ const uids = await imap.searchUidsByFromAndSince(connection, { fromAddresses: chunk, sinceDate })
323
+ for (const uid of uids) uidSet.add(uid)
324
+ }
325
+ }
326
+ // Process newest first (highest UIDs ~= most recent on standard servers).
327
+ allUids = Array.from(uidSet).sort((a, b) => b - a).slice(0, maxMessages)
328
+ remainingUids = allUids
329
+ collectedSoFar = 0
330
+ totalCandidates = allUids.length
331
+ }
332
+
333
+ if (remainingUids.length === 0) {
334
+ return { messages: [], hasMore: false, totalCandidates }
335
+ }
336
+
337
+ const batchUids = remainingUids.slice(0, PAGE_SIZE)
338
+ const stillRemaining = remainingUids.slice(PAGE_SIZE)
339
+ const uidSetExpression = batchUids.join(',')
340
+ const fetched = await imap.fetchUidRange(connection, uidSetExpression, { limit: PAGE_SIZE })
341
+
342
+ const messages: NormalizedInboundMessage[] = []
343
+ for (const item of fetched) {
344
+ const normalized = await normalizeInboundImapMessage({
345
+ rawMessage: item.rawBody,
346
+ uid: item.uid,
347
+ accountIdentifier: credentials.fromAddress,
348
+ fallbackDate: item.internalDate,
349
+ })
350
+ messages.push(normalized)
351
+ }
352
+
353
+ const newCollected = collectedSoFar + messages.length
354
+ const hasMore = stillRemaining.length > 0 && newCollected < maxMessages
355
+ const nextCursor = hasMore
356
+ ? encodeImportCursor({ remaining: stillRemaining, collected: newCollected, total: totalCandidates })
357
+ : undefined
358
+
359
+ return { messages, nextCursor, hasMore, totalCandidates }
360
+ }
361
+
362
+ async resolveContact(input: ResolveContactInput): Promise<ContactHint | null> {
363
+ return emailResolveContact(input)
364
+ }
365
+ }
366
+
367
+ function parseCredentialsOrThrow(value: unknown): ImapCredentials {
368
+ const parsed = imapCredentialsSchema.safeParse(value)
369
+ if (!parsed.success) {
370
+ const first = parsed.error.issues[0]
371
+ throw new Error(`Invalid IMAP credentials: ${first?.message ?? 'unknown validation error'}`)
372
+ }
373
+ return parsed.data
374
+ }
375
+
376
+ function pickRawMimeBuffer(raw: InboundMessage): Buffer {
377
+ const candidate = raw.raw as { rawBody?: unknown; mime?: unknown }
378
+ const value = candidate?.rawBody ?? candidate?.mime ?? raw.raw
379
+ if (Buffer.isBuffer(value)) return value
380
+ if (value instanceof Uint8Array) return Buffer.from(value)
381
+ if (typeof value === 'string') return Buffer.from(value, 'utf-8')
382
+ throw new Error('[internal] IMAP normalizeInbound requires `raw.rawBody` to be a Buffer or string MIME payload')
383
+ }
384
+
385
+ function pickAccountIdentifier(raw: InboundMessage): string {
386
+ const candidate = raw.raw as { accountIdentifier?: unknown }
387
+ const id = typeof candidate?.accountIdentifier === 'string' ? candidate.accountIdentifier : undefined
388
+ return id ?? 'unknown@imap'
389
+ }
390
+
391
+ function pickUid(raw: InboundMessage): number | undefined {
392
+ const candidate = raw.raw as { uid?: unknown }
393
+ return typeof candidate?.uid === 'number' ? candidate.uid : undefined
394
+ }
395
+
396
+ /**
397
+ * Spec B § HARD_CAP. Bound each poll's wall-clock + DB transaction size.
398
+ * Honor the caller's `limit` hint but never exceed `HARD_CAP_MAX`. A
399
+ * single poll will fetch at most this many UIDs; if more remain we set
400
+ * `hasMore: true` and the hub re-enqueues immediately.
401
+ *
402
+ * Configurable via `OM_CHANNEL_IMAP_HARD_CAP_PER_POLL` (default 200).
403
+ */
404
+ function clampHardCap(callerLimit: number | undefined): number {
405
+ const envOverride = Number.parseInt(process.env.OM_CHANNEL_IMAP_HARD_CAP_PER_POLL ?? '', 10)
406
+ const HARD_CAP_MAX = Number.isFinite(envOverride) && envOverride > 0 ? envOverride : 200
407
+ if (typeof callerLimit === 'number' && callerLimit > 0) {
408
+ return Math.min(callerLimit, HARD_CAP_MAX)
409
+ }
410
+ return HARD_CAP_MAX
411
+ }
412
+
413
+ interface ImportCursor {
414
+ remaining: number[]
415
+ collected: number
416
+ total?: number
417
+ }
418
+
419
+ function encodeImportCursor(cursor: ImportCursor): string {
420
+ return encodeCursor(cursor)
421
+ }
422
+
423
+ function decodeImportCursor(value: string | undefined): ImportCursor | null {
424
+ const parsed = decodeCursor(value)
425
+ if (!parsed || typeof parsed !== 'object') return null
426
+ const obj = parsed as { remaining?: unknown; collected?: unknown; total?: unknown }
427
+ const remaining = Array.isArray(obj.remaining)
428
+ ? obj.remaining.filter((n): n is number => typeof n === 'number' && Number.isFinite(n))
429
+ : []
430
+ const collected = typeof obj.collected === 'number' ? obj.collected : 0
431
+ const total = typeof obj.total === 'number' ? obj.total : undefined
432
+ return { remaining, collected, total }
433
+ }
434
+
435
+ function toNumberOrUndefined(value: unknown): number | undefined {
436
+ if (typeof value === 'number') return value
437
+ if (typeof value === 'string' && value.length > 0) {
438
+ const n = Number(value)
439
+ return Number.isFinite(n) ? n : undefined
440
+ }
441
+ return undefined
442
+ }
443
+
444
+ let cachedAdapter: ImapChannelAdapter | null = null
445
+
446
+ export function getImapChannelAdapter(): ImapChannelAdapter {
447
+ if (!cachedAdapter) cachedAdapter = new ImapChannelAdapter()
448
+ return cachedAdapter
449
+ }
450
+
451
+ export { ImapChannelAdapter }
@@ -0,0 +1,16 @@
1
+ import type { ChannelCapabilities } from '@open-mercato/core/modules/communication_channels/lib/adapter'
2
+ import { baseEmailCapabilities } from '@open-mercato/core/modules/communication_channels/lib/email-capabilities'
3
+
4
+ /**
5
+ * IMAP+SMTP capabilities. Polling-based (no real-time push), threaded via
6
+ * RFC2822 In-Reply-To / References, rich HTML/plain-text body, inline + regular
7
+ * attachments. No reactions, no edit/delete (only the IMAP `\Seen` flag, which
8
+ * the user controls locally and is not reliably surfaced).
9
+ *
10
+ * `fileSharing: false` (R2-M4 / F11, 2026-05-26): the adapter's `sendMessage`
11
+ * fails-fast on attachments (it doesn't yet fetch + inline attachment URLs into
12
+ * MIME bodies). Re-enable when URL-fetch + size-validation lands.
13
+ */
14
+ export const imapCapabilities: ChannelCapabilities = {
15
+ ...baseEmailCapabilities,
16
+ }