@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,131 @@
1
+ import { buildIntegrationDetailWidgetSpotId, type IntegrationBundle, type IntegrationDefinition } from '@open-mercato/shared/modules/integrations/types'
2
+
3
+ export const channelImapDetailWidgetSpotId = buildIntegrationDetailWidgetSpotId('channel_imap')
4
+
5
+ export const integration: IntegrationDefinition = {
6
+ id: 'channel_imap',
7
+ title: 'IMAP + SMTP',
8
+ description:
9
+ 'Connect any IMAP-capable mailbox (Fastmail, Proton Bridge, generic IMAP host) for inbound polling and outbound SMTP send.',
10
+ category: 'communication',
11
+ hub: 'communication_channels',
12
+ providerKey: 'imap',
13
+ icon: 'mail',
14
+ docsUrl: 'https://datatracker.ietf.org/doc/html/rfc3501',
15
+ package: '@open-mercato/channel-imap',
16
+ version: '0.1.0',
17
+ author: 'Open Mercato Team',
18
+ company: 'Open Mercato',
19
+ license: 'MIT',
20
+ tags: ['email', 'imap', 'smtp', 'polling', 'communication'],
21
+ detailPage: {
22
+ widgetSpotId: channelImapDetailWidgetSpotId,
23
+ },
24
+ apiVersions: [
25
+ {
26
+ id: 'rfc3501+rfc5321',
27
+ label: 'IMAP4 (RFC3501) + SMTP (RFC5321)',
28
+ status: 'stable',
29
+ default: true,
30
+ changelog: 'Initial IMAP4/SMTP baseline. UIDVALIDITY+UIDNEXT polling, SMTP STARTTLS+SSL.',
31
+ },
32
+ ],
33
+ credentials: {
34
+ fields: [
35
+ {
36
+ key: 'imapHost',
37
+ label: 'IMAP host',
38
+ type: 'text',
39
+ required: true,
40
+ placeholder: 'imap.fastmail.com',
41
+ helpText: 'Hostname of the IMAP server. Typically the same hostname your mail client uses.',
42
+ },
43
+ {
44
+ key: 'imapPort',
45
+ label: 'IMAP port',
46
+ type: 'text',
47
+ required: true,
48
+ placeholder: '993',
49
+ helpText: '993 for IMAPS (TLS) or 143 for STARTTLS.',
50
+ },
51
+ {
52
+ key: 'imapTls',
53
+ label: 'IMAP TLS mode',
54
+ type: 'select',
55
+ required: true,
56
+ options: [
57
+ { value: 'tls', label: 'Implicit TLS (port 993)' },
58
+ { value: 'starttls', label: 'STARTTLS (port 143)' },
59
+ { value: 'none', label: 'None (insecure — testing only)' },
60
+ ],
61
+ helpText: 'Prefer implicit TLS. STARTTLS is acceptable. None disables encryption and should only be used inside a private network for testing.',
62
+ },
63
+ {
64
+ key: 'imapUser',
65
+ label: 'IMAP username',
66
+ type: 'text',
67
+ required: true,
68
+ helpText: 'Usually your email address.',
69
+ },
70
+ {
71
+ key: 'imapPassword',
72
+ label: 'IMAP password (or app password)',
73
+ type: 'secret',
74
+ required: true,
75
+ helpText: 'Use a per-app password if your provider offers one. Stored encrypted at rest.',
76
+ },
77
+ {
78
+ key: 'smtpHost',
79
+ label: 'SMTP host',
80
+ type: 'text',
81
+ required: true,
82
+ placeholder: 'smtp.fastmail.com',
83
+ },
84
+ {
85
+ key: 'smtpPort',
86
+ label: 'SMTP port',
87
+ type: 'text',
88
+ required: true,
89
+ placeholder: '465',
90
+ helpText: '465 for implicit TLS, 587 for STARTTLS.',
91
+ },
92
+ {
93
+ key: 'smtpTls',
94
+ label: 'SMTP TLS mode',
95
+ type: 'select',
96
+ required: true,
97
+ options: [
98
+ { value: 'tls', label: 'Implicit TLS (port 465)' },
99
+ { value: 'starttls', label: 'STARTTLS (port 587)' },
100
+ { value: 'none', label: 'None (insecure — testing only)' },
101
+ ],
102
+ },
103
+ {
104
+ key: 'smtpUser',
105
+ label: 'SMTP username',
106
+ type: 'text',
107
+ required: true,
108
+ },
109
+ {
110
+ key: 'smtpPassword',
111
+ label: 'SMTP password (or app password)',
112
+ type: 'secret',
113
+ required: true,
114
+ helpText: 'Often the same as the IMAP password; provider-dependent.',
115
+ },
116
+ {
117
+ key: 'fromAddress',
118
+ label: 'From address',
119
+ type: 'text',
120
+ required: true,
121
+ placeholder: 'name@example.com',
122
+ helpText: 'Address used as the From header when sending. Must be deliverable by the SMTP server.',
123
+ },
124
+ ],
125
+ },
126
+ healthCheck: { service: 'channelImapHealthCheck' },
127
+ }
128
+
129
+ export const integrations: IntegrationDefinition[] = [integration]
130
+ export const bundles: IntegrationBundle[] = []
131
+ export const bundle: IntegrationBundle | undefined = undefined
@@ -0,0 +1,499 @@
1
+ import {
2
+ setImapClient,
3
+ type ImapClient,
4
+ } from '../imap-client'
5
+ import {
6
+ setSmtpClient,
7
+ type SmtpClient,
8
+ } from '../smtp-client'
9
+ import { getImapChannelAdapter } from '../adapter'
10
+ import { imapCapabilities } from '../capabilities'
11
+
12
+ const credentials = {
13
+ imapHost: 'imap.example.com',
14
+ imapPort: 993,
15
+ imapTls: 'tls',
16
+ imapUser: 'alice@example.com',
17
+ imapPassword: 'secret',
18
+ smtpHost: 'smtp.example.com',
19
+ smtpPort: 465,
20
+ smtpTls: 'tls',
21
+ smtpUser: 'alice@example.com',
22
+ smtpPassword: 'secret',
23
+ fromAddress: 'alice@example.com',
24
+ }
25
+
26
+ function buildSimpleMime(messageId: string, body: string): Buffer {
27
+ return Buffer.from(
28
+ [
29
+ `Message-ID: <${messageId}>`,
30
+ 'From: alice@example.com',
31
+ 'To: bob@example.com',
32
+ 'Subject: Hello',
33
+ 'Date: Wed, 21 May 2026 10:00:00 +0000',
34
+ 'MIME-Version: 1.0',
35
+ 'Content-Type: text/plain; charset=utf-8',
36
+ '',
37
+ body,
38
+ ].join('\r\n'),
39
+ 'utf-8',
40
+ )
41
+ }
42
+
43
+ afterEach(() => {
44
+ setImapClient(null)
45
+ setSmtpClient(null)
46
+ })
47
+
48
+ describe('ImapChannelAdapter wiring', () => {
49
+ it('exposes the right providerKey, channelType, and capabilities', () => {
50
+ const adapter = getImapChannelAdapter()
51
+ expect(adapter.providerKey).toBe('imap')
52
+ expect(adapter.channelType).toBe('email')
53
+ expect(adapter.capabilities).toBe(imapCapabilities)
54
+ expect(adapter.capabilities.realtimePush).toBe(false)
55
+ expect(adapter.capabilities.reactions).toBe(false)
56
+ expect(adapter.capabilities.threading).toBe(true)
57
+ })
58
+
59
+ it('exports the required optional methods (validateCredentials, fetchHistory, resolveContact)', () => {
60
+ const adapter = getImapChannelAdapter()
61
+ expect(typeof adapter.validateCredentials).toBe('function')
62
+ expect(typeof adapter.fetchHistory).toBe('function')
63
+ expect(typeof adapter.resolveContact).toBe('function')
64
+ expect(adapter.refreshCredentials).toBeUndefined()
65
+ expect(adapter.sendReaction).toBeUndefined()
66
+ expect(adapter.removeReaction).toBeUndefined()
67
+ expect(adapter.editMessage).toBeUndefined()
68
+ expect(adapter.deleteMessage).toBeUndefined()
69
+ })
70
+ })
71
+
72
+ describe('ImapChannelAdapter.sendMessage', () => {
73
+ it('sends via SMTP and best-effort appends to Sent', async () => {
74
+ const sendCalls: Array<Record<string, unknown>> = []
75
+ const appendCalls: Array<{ raw: Buffer }> = []
76
+ const smtp: SmtpClient = {
77
+ verify: async () => undefined,
78
+ send: async (_options, message) => {
79
+ sendCalls.push(message as unknown as Record<string, unknown>)
80
+ return { messageId: '<outbound@example.com>', raw: Buffer.from('RAW'), response: '250 OK' }
81
+ },
82
+ }
83
+ const imap: ImapClient = {
84
+ connectAndValidate: async () => ({ capabilities: [] }),
85
+ selectInbox: async () => ({}),
86
+ fetchUidRange: async () => [],
87
+ appendSent: async (_options, raw) => {
88
+ appendCalls.push({ raw })
89
+ },
90
+ }
91
+ setSmtpClient(smtp)
92
+ setImapClient(imap)
93
+
94
+ const adapter = getImapChannelAdapter()
95
+ const result = await adapter.sendMessage({
96
+ content: { html: '<p>Hi</p>', bodyFormat: 'html' },
97
+ credentials,
98
+ scope: { tenantId: 't', organizationId: 'o' },
99
+ metadata: { subject: 'Hi', to: ['bob@example.com'], inReplyTo: '<thread@example.com>' },
100
+ })
101
+ expect(result.status).toBe('sent')
102
+ expect(result.externalMessageId).toBe('<outbound@example.com>')
103
+ expect(sendCalls).toHaveLength(1)
104
+ expect(sendCalls[0].to).toEqual(['bob@example.com'])
105
+ expect(sendCalls[0].subject).toBe('Hi')
106
+ expect(sendCalls[0].inReplyTo).toBe('<thread@example.com>')
107
+ expect(appendCalls).toHaveLength(1)
108
+ })
109
+
110
+ it('returns failed when no recipients are provided', async () => {
111
+ setSmtpClient({ verify: async () => undefined, send: async () => ({ messageId: 'x', raw: Buffer.alloc(0) }) })
112
+ setImapClient({ connectAndValidate: async () => ({ capabilities: [] }), selectInbox: async () => ({}), fetchUidRange: async () => [], appendSent: async () => undefined })
113
+ const adapter = getImapChannelAdapter()
114
+ const result = await adapter.sendMessage({
115
+ content: { text: 'hi', bodyFormat: 'text' },
116
+ credentials,
117
+ scope: { tenantId: 't', organizationId: 'o' },
118
+ metadata: {},
119
+ })
120
+ expect(result.status).toBe('failed')
121
+ expect(result.error).toMatch(/at least one recipient/i)
122
+ })
123
+
124
+ it('swallows IMAP append failures without failing the send', async () => {
125
+ setSmtpClient({
126
+ verify: async () => undefined,
127
+ send: async () => ({ messageId: '<out@example.com>', raw: Buffer.from('RAW') }),
128
+ })
129
+ setImapClient({
130
+ connectAndValidate: async () => ({ capabilities: [] }),
131
+ selectInbox: async () => ({}),
132
+ fetchUidRange: async () => [],
133
+ appendSent: async () => {
134
+ throw new Error('NO such folder')
135
+ },
136
+ })
137
+ const adapter = getImapChannelAdapter()
138
+ const result = await adapter.sendMessage({
139
+ content: { text: 'hi', bodyFormat: 'text' },
140
+ credentials,
141
+ scope: { tenantId: 't', organizationId: 'o' },
142
+ metadata: { to: ['bob@example.com'] },
143
+ })
144
+ expect(result.status).toBe('sent')
145
+ })
146
+ })
147
+
148
+ describe('ImapChannelAdapter.fetchHistory', () => {
149
+ // Spec B § Bounded, cursor-driven IMAP inbound.
150
+ //
151
+ // The "30-min wall-clock window" approach was replaced with zero-history
152
+ // bootstrap + incremental UID FETCH. UIDVALIDITY mismatch now triggers a
153
+ // bootstrap (zero messages, cursor persisted) rather than a 1:* full
154
+ // resync — that path uses the explicit `/import-history` endpoint.
155
+
156
+ it('bootstrap: no prior cursor → persists UIDVALIDITY + UIDNEXT, fetches zero messages', async () => {
157
+ const fetchCalls: string[] = []
158
+ const imap: ImapClient = {
159
+ connectAndValidate: async () => ({ capabilities: [] }),
160
+ selectInbox: async () => ({ uidValidity: 1, uidNext: 60 }),
161
+ fetchUidRange: async (_options, range) => {
162
+ fetchCalls.push(range)
163
+ return []
164
+ },
165
+ appendSent: async () => undefined,
166
+ }
167
+ setImapClient(imap)
168
+ setSmtpClient({ verify: async () => undefined, send: async () => ({ messageId: 'x', raw: Buffer.alloc(0) }) })
169
+ const adapter = getImapChannelAdapter()
170
+ const page = await adapter.fetchHistory!({
171
+ conversationId: 'INBOX',
172
+ credentials,
173
+ scope: { tenantId: 't', organizationId: 'o' },
174
+ } as Parameters<NonNullable<ReturnType<typeof getImapChannelAdapter>['fetchHistory']>>[0])
175
+ expect(fetchCalls).toEqual([]) // ZERO fetches on bootstrap — by design
176
+ expect(page.messages).toHaveLength(0)
177
+ expect(page.hasMore).toBe(false)
178
+ const decoded = JSON.parse(Buffer.from(page.nextCursor!, 'base64').toString('utf-8'))
179
+ expect(decoded.uidValidity).toBe(1)
180
+ expect(decoded.uidNext).toBe(60)
181
+ })
182
+
183
+ it('UIDVALIDITY mismatch: discards cursor and re-bootstraps (no full resync)', async () => {
184
+ const fetchCalls: string[] = []
185
+ const imap: ImapClient = {
186
+ connectAndValidate: async () => ({ capabilities: [] }),
187
+ selectInbox: async () => ({ uidValidity: 999, uidNext: 50 }),
188
+ fetchUidRange: async (_options, range) => {
189
+ fetchCalls.push(range)
190
+ return []
191
+ },
192
+ appendSent: async () => undefined,
193
+ }
194
+ setImapClient(imap)
195
+ setSmtpClient({ verify: async () => undefined, send: async () => ({ messageId: 'x', raw: Buffer.alloc(0) }) })
196
+ const adapter = getImapChannelAdapter()
197
+ const page = await adapter.fetchHistory!({
198
+ conversationId: 'INBOX',
199
+ credentials,
200
+ scope: { tenantId: 't', organizationId: 'o' },
201
+ ...({ channelState: { uidValidity: 1, uidNext: 40 } } as unknown as Record<string, unknown>),
202
+ } as Parameters<NonNullable<ReturnType<typeof getImapChannelAdapter>['fetchHistory']>>[0])
203
+ // UIDVALIDITY mismatch triggers bootstrap — no fetch.
204
+ expect(fetchCalls).toEqual([])
205
+ expect(page.messages).toHaveLength(0)
206
+ expect(page.hasMore).toBe(false)
207
+ const decoded = JSON.parse(Buffer.from(page.nextCursor!, 'base64').toString('utf-8'))
208
+ expect(decoded.uidValidity).toBe(999)
209
+ expect(decoded.uidNext).toBe(50)
210
+ })
211
+
212
+ it('fetches incremental UID range from the stored cursor', async () => {
213
+ const fetchCalls: string[] = []
214
+ const imap: ImapClient = {
215
+ connectAndValidate: async () => ({ capabilities: [] }),
216
+ selectInbox: async () => ({ uidValidity: 1, uidNext: 60 }),
217
+ fetchUidRange: async (_options, range) => {
218
+ fetchCalls.push(range)
219
+ return [
220
+ { uid: 55, rawBody: buildSimpleMime('c@x', 'C'), internalDate: new Date('2026-05-03T00:00:00Z') },
221
+ ]
222
+ },
223
+ appendSent: async () => undefined,
224
+ }
225
+ setImapClient(imap)
226
+ setSmtpClient({ verify: async () => undefined, send: async () => ({ messageId: 'x', raw: Buffer.alloc(0) }) })
227
+ const adapter = getImapChannelAdapter()
228
+ const page = await adapter.fetchHistory!({
229
+ conversationId: 'INBOX',
230
+ credentials,
231
+ scope: { tenantId: 't', organizationId: 'o' },
232
+ ...({ channelState: { uidValidity: 1, uidNext: 50 } } as unknown as Record<string, unknown>),
233
+ } as Parameters<NonNullable<ReturnType<typeof getImapChannelAdapter>['fetchHistory']>>[0])
234
+ expect(fetchCalls).toEqual(['50:*'])
235
+ expect(page.messages).toHaveLength(1)
236
+ expect(page.hasMore).toBe(false)
237
+ // Regression (inbound-skip bug): on drain, the cursor must anchor to the
238
+ // highest FETCHED uid + 1 (55 + 1 = 56), NOT the server's UIDNEXT (60).
239
+ // Jumping to serverUidNext steps over INBOX messages that sit at a UID
240
+ // below it (Gmail UID gaps from labels/threads), permanently dropping them.
241
+ const decoded = JSON.parse(Buffer.from(page.nextCursor!, 'base64').toString('utf-8'))
242
+ expect(decoded.uidNext).toBe(56)
243
+ })
244
+
245
+ it('signals hasMore=true when more UIDs remain than HARD_CAP', async () => {
246
+ // Force HARD_CAP to a small value via env override so the test is bounded.
247
+ const originalCap = process.env.OM_CHANNEL_IMAP_HARD_CAP_PER_POLL
248
+ process.env.OM_CHANNEL_IMAP_HARD_CAP_PER_POLL = '2'
249
+ try {
250
+ const fetchCalls: string[] = []
251
+ const imap: ImapClient = {
252
+ connectAndValidate: async () => ({ capabilities: [] }),
253
+ selectInbox: async () => ({ uidValidity: 1, uidNext: 100 }),
254
+ fetchUidRange: async (_options, range) => {
255
+ fetchCalls.push(range)
256
+ // Return 3 messages (HARD_CAP+1) so the probe detects "more remain".
257
+ return [
258
+ { uid: 50, rawBody: buildSimpleMime('a@x', 'A'), internalDate: new Date() },
259
+ { uid: 51, rawBody: buildSimpleMime('b@x', 'B'), internalDate: new Date() },
260
+ { uid: 52, rawBody: buildSimpleMime('c@x', 'C'), internalDate: new Date() },
261
+ ]
262
+ },
263
+ appendSent: async () => undefined,
264
+ }
265
+ setImapClient(imap)
266
+ setSmtpClient({ verify: async () => undefined, send: async () => ({ messageId: 'x', raw: Buffer.alloc(0) }) })
267
+ const adapter = getImapChannelAdapter()
268
+ const page = await adapter.fetchHistory!({
269
+ conversationId: 'INBOX',
270
+ credentials,
271
+ scope: { tenantId: 't', organizationId: 'o' },
272
+ ...({ channelState: { uidValidity: 1, uidNext: 50 } } as unknown as Record<string, unknown>),
273
+ } as Parameters<NonNullable<ReturnType<typeof getImapChannelAdapter>['fetchHistory']>>[0])
274
+ expect(page.messages).toHaveLength(2) // capped at HARD_CAP
275
+ expect(page.hasMore).toBe(true)
276
+ const decoded = JSON.parse(Buffer.from(page.nextCursor!, 'base64').toString('utf-8'))
277
+ // Cursor advances PAST the last fetched UID (so next poll picks up
278
+ // from highest+1), not all the way to server uidNext.
279
+ expect(decoded.uidNext).toBe(52)
280
+ } finally {
281
+ if (originalCap === undefined) delete process.env.OM_CHANNEL_IMAP_HARD_CAP_PER_POLL
282
+ else process.env.OM_CHANNEL_IMAP_HARD_CAP_PER_POLL = originalCap
283
+ }
284
+ })
285
+ })
286
+
287
+ describe('ImapChannelAdapter.importHistory', () => {
288
+ // Spec B § Phase B6 — operator-triggered backlog import. Distinct from
289
+ // fetchHistory's zero-history bootstrap: this reaches backward in time and
290
+ // pulls messages matching SEARCH SINCE + optional FROM list.
291
+
292
+ function makeImap(args: {
293
+ onSearch: (criteria: { fromAddresses?: string[]; sinceDate?: Date }) => number[]
294
+ onFetch: (range: string) => Array<{ uid: number; rawBody: Buffer; internalDate?: Date }>
295
+ }): ImapClient {
296
+ return {
297
+ connectAndValidate: async () => ({ capabilities: [] }),
298
+ selectInbox: async () => ({ uidValidity: 1, uidNext: 100 }),
299
+ fetchUidRange: async (_options, range) => args.onFetch(range),
300
+ searchUidsByFromAndSince: async (_options, criteria) => args.onSearch(criteria),
301
+ appendSent: async () => undefined,
302
+ } as ImapClient
303
+ }
304
+
305
+ it('queries SEARCH SINCE only when no contactEmails provided, then fetches the page', async () => {
306
+ const searchCalls: Array<{ fromAddresses?: string[]; sinceDate?: Date }> = []
307
+ const fetchCalls: string[] = []
308
+ setImapClient(makeImap({
309
+ onSearch: (c) => {
310
+ searchCalls.push(c)
311
+ return [101, 102, 103]
312
+ },
313
+ onFetch: (range) => {
314
+ fetchCalls.push(range)
315
+ return [
316
+ { uid: 103, rawBody: buildSimpleMime('a@x', 'A') },
317
+ { uid: 102, rawBody: buildSimpleMime('b@x', 'B') },
318
+ { uid: 101, rawBody: buildSimpleMime('c@x', 'C') },
319
+ ]
320
+ },
321
+ }))
322
+ const adapter = getImapChannelAdapter()
323
+ const page = await adapter.importHistory!({
324
+ credentials,
325
+ scope: { tenantId: 't', organizationId: 'o' },
326
+ sinceDays: 14,
327
+ })
328
+ expect(searchCalls).toHaveLength(1)
329
+ expect(searchCalls[0].fromAddresses).toBeUndefined()
330
+ expect(searchCalls[0].sinceDate).toBeInstanceOf(Date)
331
+ expect(fetchCalls[0]).toBe('103,102,101') // newest UIDs first
332
+ expect(page.messages).toHaveLength(3)
333
+ expect(page.hasMore).toBe(false)
334
+ expect(page.totalCandidates).toBe(3)
335
+ expect(page.nextCursor).toBeUndefined()
336
+ })
337
+
338
+ it('chunks contactEmails to ≤30 per SEARCH and unions results', async () => {
339
+ const searchCalls: Array<{ fromAddresses?: string[]; sinceDate?: Date }> = []
340
+ setImapClient(makeImap({
341
+ onSearch: (c) => {
342
+ searchCalls.push(c)
343
+ return (c.fromAddresses ?? []).map((_addr, i) => 1000 + searchCalls.length * 100 + i)
344
+ },
345
+ onFetch: () => [],
346
+ }))
347
+ const senders = Array.from({ length: 65 }, (_v, i) => `s${i}@example.com`)
348
+ const adapter = getImapChannelAdapter()
349
+ const page = await adapter.importHistory!({
350
+ credentials,
351
+ scope: { tenantId: 't', organizationId: 'o' },
352
+ sinceDays: 30,
353
+ contactEmails: senders,
354
+ })
355
+ expect(searchCalls).toHaveLength(3) // ceil(65/30) = 3 chunks
356
+ expect(searchCalls.every((c) => (c.fromAddresses ?? []).length <= 30)).toBe(true)
357
+ // Union of UIDs returned across chunks: 30 + 30 + 5 = 65
358
+ expect(page.totalCandidates).toBe(65)
359
+ })
360
+
361
+ it('paginates: PAGE_SIZE-bounded batches with cursor resumption', async () => {
362
+ const originalCap = process.env.OM_CHANNEL_IMAP_HARD_CAP_PER_POLL
363
+ process.env.OM_CHANNEL_IMAP_HARD_CAP_PER_POLL = '2'
364
+ try {
365
+ const fetchCalls: string[] = []
366
+ setImapClient(makeImap({
367
+ onSearch: () => [10, 20, 30, 40, 50],
368
+ onFetch: (range) => {
369
+ fetchCalls.push(range)
370
+ return range.split(',').map((u) => ({ uid: Number(u), rawBody: buildSimpleMime(`m${u}@x`, 'X') }))
371
+ },
372
+ }))
373
+ const adapter = getImapChannelAdapter()
374
+ const page1 = await adapter.importHistory!({
375
+ credentials,
376
+ scope: { tenantId: 't', organizationId: 'o' },
377
+ sinceDays: 30,
378
+ })
379
+ expect(page1.messages).toHaveLength(2)
380
+ expect(page1.hasMore).toBe(true)
381
+ expect(page1.nextCursor).toBeTruthy()
382
+ expect(fetchCalls[0]).toBe('50,40') // newest first, capped at 2
383
+
384
+ const page2 = await adapter.importHistory!({
385
+ credentials,
386
+ scope: { tenantId: 't', organizationId: 'o' },
387
+ sinceDays: 30,
388
+ cursor: page1.nextCursor,
389
+ })
390
+ expect(page2.messages).toHaveLength(2)
391
+ expect(page2.hasMore).toBe(true)
392
+ expect(fetchCalls[1]).toBe('30,20')
393
+
394
+ const page3 = await adapter.importHistory!({
395
+ credentials,
396
+ scope: { tenantId: 't', organizationId: 'o' },
397
+ sinceDays: 30,
398
+ cursor: page2.nextCursor,
399
+ })
400
+ expect(page3.messages).toHaveLength(1)
401
+ expect(page3.hasMore).toBe(false)
402
+ expect(fetchCalls[2]).toBe('10')
403
+ } finally {
404
+ if (originalCap === undefined) delete process.env.OM_CHANNEL_IMAP_HARD_CAP_PER_POLL
405
+ else process.env.OM_CHANNEL_IMAP_HARD_CAP_PER_POLL = originalCap
406
+ }
407
+ })
408
+
409
+ it('respects maxMessages cap', async () => {
410
+ setImapClient(makeImap({
411
+ onSearch: () => Array.from({ length: 50 }, (_v, i) => i + 1),
412
+ onFetch: () => [],
413
+ }))
414
+ const adapter = getImapChannelAdapter()
415
+ const page = await adapter.importHistory!({
416
+ credentials,
417
+ scope: { tenantId: 't', organizationId: 'o' },
418
+ sinceDays: 30,
419
+ maxMessages: 10,
420
+ })
421
+ expect(page.totalCandidates).toBe(10)
422
+ })
423
+
424
+ it('clamps sinceDays to [1, 365]', async () => {
425
+ const sinceDates: Date[] = []
426
+ setImapClient(makeImap({
427
+ onSearch: (c) => {
428
+ if (c.sinceDate) sinceDates.push(c.sinceDate)
429
+ return []
430
+ },
431
+ onFetch: () => [],
432
+ }))
433
+ const adapter = getImapChannelAdapter()
434
+ await adapter.importHistory!({
435
+ credentials,
436
+ scope: { tenantId: 't', organizationId: 'o' },
437
+ sinceDays: 9999,
438
+ })
439
+ await adapter.importHistory!({
440
+ credentials,
441
+ scope: { tenantId: 't', organizationId: 'o' },
442
+ sinceDays: 0,
443
+ })
444
+ expect(sinceDates).toHaveLength(2)
445
+ const now = Date.now()
446
+ const ms365 = 365 * 24 * 60 * 60 * 1000
447
+ const ms1 = 1 * 24 * 60 * 60 * 1000
448
+ expect(now - sinceDates[0].getTime()).toBeGreaterThan(ms365 - 5000)
449
+ expect(now - sinceDates[0].getTime()).toBeLessThan(ms365 + 5000)
450
+ expect(now - sinceDates[1].getTime()).toBeGreaterThan(ms1 - 5000)
451
+ expect(now - sinceDates[1].getTime()).toBeLessThan(ms1 + 5000)
452
+ })
453
+ })
454
+
455
+ describe('ImapChannelAdapter.verifyWebhook + getStatus', () => {
456
+ it('verifyWebhook returns a non-message event since IMAP has no webhook', async () => {
457
+ const adapter = getImapChannelAdapter()
458
+ const event = await adapter.verifyWebhook({
459
+ rawBody: '',
460
+ headers: {},
461
+ credentials,
462
+ scope: { tenantId: 't', organizationId: 'o' },
463
+ })
464
+ expect(event.eventType).toBe('other')
465
+ })
466
+
467
+ it('getStatus returns sent as best-effort placeholder', async () => {
468
+ const adapter = getImapChannelAdapter()
469
+ const status = await adapter.getStatus({
470
+ externalMessageId: 'x',
471
+ credentials,
472
+ scope: { tenantId: 't', organizationId: 'o' },
473
+ })
474
+ expect(status.status).toBe('sent')
475
+ })
476
+ })
477
+
478
+ describe('ImapChannelAdapter.resolveContact', () => {
479
+ it('returns an email-only hint for email-shaped identifiers', async () => {
480
+ const adapter = getImapChannelAdapter()
481
+ const hint = await adapter.resolveContact!({
482
+ senderIdentifier: 'alice@example.com',
483
+ senderDisplayName: 'Alice',
484
+ credentials,
485
+ scope: { tenantId: 't', organizationId: 'o' },
486
+ })
487
+ expect(hint).toEqual({ email: 'alice@example.com', displayName: 'Alice' })
488
+ })
489
+
490
+ it('returns null when sender is not email-shaped', async () => {
491
+ const adapter = getImapChannelAdapter()
492
+ const hint = await adapter.resolveContact!({
493
+ senderIdentifier: 'no-at-sign',
494
+ credentials,
495
+ scope: { tenantId: 't', organizationId: 'o' },
496
+ })
497
+ expect(hint).toBeNull()
498
+ })
499
+ })