@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.
- package/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +56 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.js +62 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-002.spec.js +19 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-002.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-003.spec.js +16 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-003.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-021.spec.js +26 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-021.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-022.spec.js +27 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-022.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-023.spec.js +15 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-023.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-024.spec.js +15 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-024.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-025.spec.js +6 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-025.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-026.spec.js +6 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-026.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-027.spec.js +6 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-027.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-028.spec.js +6 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-028.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-029.spec.js +48 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-029.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-030.spec.js +6 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-030.spec.js.map +7 -0
- package/dist/modules/channel_imap/acl.js +10 -0
- package/dist/modules/channel_imap/acl.js.map +7 -0
- package/dist/modules/channel_imap/di.js +23 -0
- package/dist/modules/channel_imap/di.js.map +7 -0
- package/dist/modules/channel_imap/index.js +9 -0
- package/dist/modules/channel_imap/index.js.map +7 -0
- package/dist/modules/channel_imap/integration.js +135 -0
- package/dist/modules/channel_imap/integration.js.map +7 -0
- package/dist/modules/channel_imap/lib/adapter.js +291 -0
- package/dist/modules/channel_imap/lib/adapter.js.map +7 -0
- package/dist/modules/channel_imap/lib/capabilities.js +8 -0
- package/dist/modules/channel_imap/lib/capabilities.js.map +7 -0
- package/dist/modules/channel_imap/lib/convert-outbound.js +54 -0
- package/dist/modules/channel_imap/lib/convert-outbound.js.map +7 -0
- package/dist/modules/channel_imap/lib/credentials.js +104 -0
- package/dist/modules/channel_imap/lib/credentials.js.map +7 -0
- package/dist/modules/channel_imap/lib/health.js +39 -0
- package/dist/modules/channel_imap/lib/health.js.map +7 -0
- package/dist/modules/channel_imap/lib/host-pinning.js +34 -0
- package/dist/modules/channel_imap/lib/host-pinning.js.map +7 -0
- package/dist/modules/channel_imap/lib/imap-client.js +210 -0
- package/dist/modules/channel_imap/lib/imap-client.js.map +7 -0
- package/dist/modules/channel_imap/lib/normalize-inbound.js +19 -0
- package/dist/modules/channel_imap/lib/normalize-inbound.js.map +7 -0
- package/dist/modules/channel_imap/lib/smtp-client.js +113 -0
- package/dist/modules/channel_imap/lib/smtp-client.js.map +7 -0
- package/dist/modules/channel_imap/lib/transport.js +17 -0
- package/dist/modules/channel_imap/lib/transport.js.map +7 -0
- package/dist/modules/channel_imap/lib/validate-credentials.js +69 -0
- package/dist/modules/channel_imap/lib/validate-credentials.js.map +7 -0
- package/dist/modules/channel_imap/setup.js +25 -0
- package/dist/modules/channel_imap/setup.js.map +7 -0
- package/dist/modules/channel_imap/widgets/injection/connect/widget.client.js +337 -0
- package/dist/modules/channel_imap/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_imap/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_imap/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_imap/widgets/injection-table.js +14 -0
- package/dist/modules/channel_imap/widgets/injection-table.js.map +7 -0
- package/jest.config.cjs +34 -0
- package/package.json +99 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.ts +80 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-002.spec.ts +28 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-003.spec.ts +23 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-021.spec.ts +40 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-022.spec.ts +38 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-023.spec.ts +31 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-024.spec.ts +27 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-025.spec.ts +23 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-026.spec.ts +18 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-027.spec.ts +18 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-028.spec.ts +19 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-029.spec.ts +72 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-030.spec.ts +19 -0
- package/src/modules/channel_imap/acl.ts +6 -0
- package/src/modules/channel_imap/di.ts +26 -0
- package/src/modules/channel_imap/index.ts +6 -0
- package/src/modules/channel_imap/integration.ts +131 -0
- package/src/modules/channel_imap/lib/__tests__/adapter.test.ts +499 -0
- package/src/modules/channel_imap/lib/__tests__/convert-outbound.test.ts +73 -0
- package/src/modules/channel_imap/lib/__tests__/credentials.test.ts +154 -0
- package/src/modules/channel_imap/lib/__tests__/host-pinning.test.ts +68 -0
- package/src/modules/channel_imap/lib/__tests__/imap-client.test.ts +180 -0
- package/src/modules/channel_imap/lib/__tests__/normalize-inbound.test.ts +126 -0
- package/src/modules/channel_imap/lib/__tests__/transport.test.ts +68 -0
- package/src/modules/channel_imap/lib/__tests__/validate-credentials.test.ts +156 -0
- package/src/modules/channel_imap/lib/adapter.ts +451 -0
- package/src/modules/channel_imap/lib/capabilities.ts +16 -0
- package/src/modules/channel_imap/lib/convert-outbound.ts +79 -0
- package/src/modules/channel_imap/lib/credentials.ts +172 -0
- package/src/modules/channel_imap/lib/health.ts +70 -0
- package/src/modules/channel_imap/lib/host-pinning.ts +59 -0
- package/src/modules/channel_imap/lib/imap-client.ts +382 -0
- package/src/modules/channel_imap/lib/normalize-inbound.ts +47 -0
- package/src/modules/channel_imap/lib/smtp-client.ts +214 -0
- package/src/modules/channel_imap/lib/transport.ts +37 -0
- package/src/modules/channel_imap/lib/validate-credentials.ts +98 -0
- package/src/modules/channel_imap/setup.ts +34 -0
- package/src/modules/channel_imap/widgets/injection/connect/widget.client.tsx +359 -0
- package/src/modules/channel_imap/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_imap/widgets/injection-table.ts +12 -0
- package/tsconfig.json +9 -0
- 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
|
+
})
|