@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,73 @@
1
+ import { convertOutboundForEmail } from '../convert-outbound'
2
+
3
+ describe('convertOutboundForEmail', () => {
4
+ it('produces text + html from html input', async () => {
5
+ const native = await convertOutboundForEmail({
6
+ body: '<p>Hello <strong>world</strong></p>',
7
+ bodyFormat: 'html',
8
+ channelMetadata: {
9
+ subject: 'Hi',
10
+ to: ['bob@example.com'],
11
+ },
12
+ })
13
+ expect(native.content.html).toContain('<strong>world</strong>')
14
+ expect(native.content.text).toContain('Hello world')
15
+ expect(native.metadata).toMatchObject({ subject: 'Hi', to: ['bob@example.com'] })
16
+ })
17
+
18
+ it('keeps plain text untouched', async () => {
19
+ const native = await convertOutboundForEmail({
20
+ body: 'Hello\n\nworld',
21
+ bodyFormat: 'text',
22
+ channelMetadata: { to: 'bob@example.com', subject: 'Re: hi' },
23
+ })
24
+ expect(native.content.text).toBe('Hello\n\nworld')
25
+ expect(native.content.html).toBeUndefined()
26
+ expect(native.metadata?.to).toEqual(['bob@example.com'])
27
+ })
28
+
29
+ it('splits comma-separated address strings into arrays', async () => {
30
+ const native = await convertOutboundForEmail({
31
+ body: 'hi',
32
+ bodyFormat: 'text',
33
+ channelMetadata: { to: 'a@x.com, b@y.com', cc: 'c@z.com;d@z.com' },
34
+ })
35
+ expect(native.metadata?.to).toEqual(['a@x.com', 'b@y.com'])
36
+ expect(native.metadata?.cc).toEqual(['c@z.com', 'd@z.com'])
37
+ })
38
+
39
+ it('preserves threading headers from channelMetadata', async () => {
40
+ const native = await convertOutboundForEmail({
41
+ body: 'reply',
42
+ bodyFormat: 'text',
43
+ channelMetadata: {
44
+ to: ['root@example.com'],
45
+ inReplyTo: '<root@example.com>',
46
+ references: ['<root@example.com>', '<parent@example.com>'],
47
+ },
48
+ })
49
+ expect(native.metadata?.inReplyTo).toBe('<root@example.com>')
50
+ expect(native.metadata?.references).toEqual(['<root@example.com>', '<parent@example.com>'])
51
+ })
52
+
53
+ it('rejects payloads without recipients', async () => {
54
+ await expect(
55
+ convertOutboundForEmail({ body: 'hi', bodyFormat: 'text', channelMetadata: {} }),
56
+ ).rejects.toThrow(/at least one recipient/i)
57
+ })
58
+
59
+ it('strips CRLF from header-shaped fields (defense-in-depth)', async () => {
60
+ const native = await convertOutboundForEmail({
61
+ body: 'hi',
62
+ bodyFormat: 'text',
63
+ channelMetadata: {
64
+ subject: 'Hello\r\nBcc: attacker@evil.com',
65
+ to: ['bob@example.com'],
66
+ inReplyTo: '<root@example.com>\r\nX-Injected: 1',
67
+ },
68
+ })
69
+ expect(native.metadata?.subject).toBe('Hello Bcc: attacker@evil.com')
70
+ expect(native.metadata?.subject).not.toMatch(/[\r\n]/)
71
+ expect(native.metadata?.inReplyTo).not.toMatch(/[\r\n]/)
72
+ })
73
+ })
@@ -0,0 +1,154 @@
1
+ import { imapCredentialsSchema, imapChannelStateSchema, isInternalHost } from '../credentials'
2
+
3
+ describe('imapCredentialsSchema', () => {
4
+ const valid = {
5
+ imapHost: 'imap.example.com',
6
+ imapPort: 993,
7
+ imapTls: 'tls' as const,
8
+ imapUser: 'alice@example.com',
9
+ imapPassword: 'secret',
10
+ smtpHost: 'smtp.example.com',
11
+ smtpPort: 465,
12
+ smtpTls: 'tls' as const,
13
+ smtpUser: 'alice@example.com',
14
+ smtpPassword: 'secret',
15
+ fromAddress: 'alice@example.com',
16
+ }
17
+
18
+ it('accepts a fully populated payload', () => {
19
+ expect(imapCredentialsSchema.parse(valid)).toMatchObject(valid)
20
+ })
21
+
22
+ it('coerces string ports into numbers', () => {
23
+ const parsed = imapCredentialsSchema.parse({
24
+ ...valid,
25
+ imapPort: '993' as unknown as number,
26
+ smtpPort: '587' as unknown as number,
27
+ smtpTls: 'starttls' as const,
28
+ })
29
+ expect(parsed.imapPort).toBe(993)
30
+ expect(parsed.smtpPort).toBe(587)
31
+ })
32
+
33
+ it('rejects out-of-range ports', () => {
34
+ expect(() => imapCredentialsSchema.parse({ ...valid, imapPort: 0 })).toThrow(/IMAP port/i)
35
+ expect(() => imapCredentialsSchema.parse({ ...valid, smtpPort: 70_000 })).toThrow(/SMTP port/i)
36
+ })
37
+
38
+ it('rejects missing required strings', () => {
39
+ expect(() => imapCredentialsSchema.parse({ ...valid, imapHost: '' })).toThrow(/IMAP host/i)
40
+ expect(() => imapCredentialsSchema.parse({ ...valid, imapPassword: '' })).toThrow(/IMAP password/i)
41
+ expect(() => imapCredentialsSchema.parse({ ...valid, smtpPassword: '' })).toThrow(/SMTP password/i)
42
+ })
43
+
44
+ it('rejects invalid TLS modes', () => {
45
+ expect(() => imapCredentialsSchema.parse({ ...valid, imapTls: 'plain' as never })).toThrow()
46
+ expect(() => imapCredentialsSchema.parse({ ...valid, smtpTls: 'tls/1.3' as never })).toThrow()
47
+ })
48
+
49
+ it('rejects non-email From address', () => {
50
+ expect(() => imapCredentialsSchema.parse({ ...valid, fromAddress: 'not-an-email' })).toThrow(
51
+ /From address must be a valid email/i,
52
+ )
53
+ })
54
+
55
+ it('rejects an internal IMAP host (SSRF guard wired into the schema)', () => {
56
+ expect(() => imapCredentialsSchema.parse({ ...valid, imapHost: '169.254.169.254' })).toThrow(
57
+ /private or loopback/i,
58
+ )
59
+ expect(() => imapCredentialsSchema.parse({ ...valid, smtpHost: 'localhost' })).toThrow(/private or loopback/i)
60
+ })
61
+
62
+ it('honors the OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS escape hatch', () => {
63
+ const previous = process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS
64
+ process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS = 'true'
65
+ try {
66
+ expect(() =>
67
+ imapCredentialsSchema.parse({ ...valid, imapHost: '127.0.0.1', smtpHost: '127.0.0.1' }),
68
+ ).not.toThrow()
69
+ } finally {
70
+ if (previous === undefined) delete process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS
71
+ else process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS = previous
72
+ }
73
+ })
74
+ })
75
+
76
+ describe('isInternalHost (SSRF guard)', () => {
77
+ const blocked = [
78
+ 'localhost',
79
+ 'LOCALHOST',
80
+ 'foo.localhost',
81
+ 'localhost6',
82
+ 'ip6-localhost',
83
+ 'metadata.google.internal',
84
+ '127.0.0.1',
85
+ '127.1',
86
+ '10.0.0.5',
87
+ '172.16.0.1',
88
+ '172.31.255.255',
89
+ '192.168.1.1',
90
+ '169.254.169.254',
91
+ '100.64.0.1',
92
+ '0.0.0.0',
93
+ '2130706433',
94
+ '0x7f.0.0.1',
95
+ '0177.0.0.1',
96
+ '::1',
97
+ '[::1]',
98
+ '::',
99
+ '[::]',
100
+ '0000:0000:0000:0000:0000:0000:0000:0001',
101
+ 'fc00::1',
102
+ 'fd12:3456::1',
103
+ 'fe80::1',
104
+ '::ffff:127.0.0.1',
105
+ '::ffff:169.254.169.254',
106
+ ]
107
+
108
+ const allowed = [
109
+ 'imap.example.com',
110
+ 'smtp.fastmail.com',
111
+ 'mail.proton.me',
112
+ '8.8.8.8',
113
+ '1.1.1.1',
114
+ '203.0.113.10',
115
+ '2001:4860:4860::8888',
116
+ ]
117
+
118
+ it.each(blocked)('blocks internal/obfuscated host %s', (host) => {
119
+ expect(isInternalHost(host)).toBe(true)
120
+ })
121
+
122
+ it.each(allowed)('allows public host %s', (host) => {
123
+ expect(isInternalHost(host)).toBe(false)
124
+ })
125
+
126
+ it('treats an empty host as not-internal (length validation handles it)', () => {
127
+ expect(isInternalHost('')).toBe(false)
128
+ expect(isInternalHost(' ')).toBe(false)
129
+ })
130
+ })
131
+
132
+ describe('imapChannelStateSchema', () => {
133
+ it('accepts numeric uidValidity and uidNext', () => {
134
+ expect(imapChannelStateSchema.parse({ uidValidity: 123, uidNext: 456 })).toMatchObject({
135
+ uidValidity: 123,
136
+ uidNext: 456,
137
+ })
138
+ })
139
+
140
+ it('accepts string uidValidity (some servers ship 64-bit values as strings)', () => {
141
+ expect(imapChannelStateSchema.parse({ uidValidity: '9007199254740993' })).toMatchObject({
142
+ uidValidity: '9007199254740993',
143
+ })
144
+ })
145
+
146
+ it('accepts empty state', () => {
147
+ expect(imapChannelStateSchema.parse({})).toEqual({})
148
+ })
149
+
150
+ it('passes through additional provider fields without erroring', () => {
151
+ const state = imapChannelStateSchema.parse({ uidValidity: 1, customMarker: 'hi' })
152
+ expect(state).toMatchObject({ uidValidity: 1, customMarker: 'hi' })
153
+ })
154
+ })
@@ -0,0 +1,68 @@
1
+ import { resolveSafeHostAddress, type HostLookup } from '../host-pinning'
2
+
3
+ function fakeLookup(records: Array<{ address: string; family: number }>): HostLookup {
4
+ return jest.fn(async () => records)
5
+ }
6
+
7
+ describe('resolveSafeHostAddress — connect-time SSRF pinning', () => {
8
+ afterEach(() => {
9
+ delete process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS
10
+ })
11
+
12
+ it('resolves a public hostname to its IP and pins the original hostname as TLS servername', async () => {
13
+ const lookup = fakeLookup([{ address: '93.184.216.34', family: 4 }])
14
+ const result = await resolveSafeHostAddress('imap.example.com', { lookup })
15
+ expect(result).toEqual({ host: '93.184.216.34', servername: 'imap.example.com' })
16
+ expect(lookup).toHaveBeenCalledWith('imap.example.com')
17
+ })
18
+
19
+ it('rejects a hostname that resolves to a private address (DNS rebinding)', async () => {
20
+ const lookup = fakeLookup([{ address: '10.0.0.5', family: 4 }])
21
+ await expect(resolveSafeHostAddress('rebind.attacker.test', { lookup })).rejects.toThrow(
22
+ /private or loopback/i,
23
+ )
24
+ })
25
+
26
+ it('rejects when ANY resolved address is internal (cloud metadata in a mixed record set)', async () => {
27
+ const lookup = fakeLookup([
28
+ { address: '93.184.216.34', family: 4 },
29
+ { address: '169.254.169.254', family: 4 },
30
+ ])
31
+ await expect(resolveSafeHostAddress('mixed.attacker.test', { lookup })).rejects.toThrow(
32
+ /private or loopback/i,
33
+ )
34
+ })
35
+
36
+ it('rejects a hostname that resolves to IPv6 loopback', async () => {
37
+ const lookup = fakeLookup([{ address: '::1', family: 6 }])
38
+ await expect(resolveSafeHostAddress('v6.attacker.test', { lookup })).rejects.toThrow(
39
+ /private or loopback/i,
40
+ )
41
+ })
42
+
43
+ it('returns a literal public IP unchanged with no servername and does not resolve it', async () => {
44
+ const lookup = fakeLookup([{ address: '203.0.113.7', family: 4 }])
45
+ const result = await resolveSafeHostAddress('93.184.216.34', { lookup })
46
+ expect(result).toEqual({ host: '93.184.216.34' })
47
+ expect(lookup).not.toHaveBeenCalled()
48
+ })
49
+
50
+ it('rejects a literal internal IP even though the schema should have caught it (defense in depth)', async () => {
51
+ await expect(resolveSafeHostAddress('169.254.169.254')).rejects.toThrow(/private or loopback/i)
52
+ })
53
+
54
+ it('throws when the hostname does not resolve to any address', async () => {
55
+ const lookup = fakeLookup([])
56
+ await expect(resolveSafeHostAddress('nxdomain.attacker.test', { lookup })).rejects.toThrow(
57
+ /did not resolve/i,
58
+ )
59
+ })
60
+
61
+ it('skips resolution and returns the host verbatim when OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS=true', async () => {
62
+ process.env.OM_CHANNEL_IMAP_ALLOW_INTERNAL_HOSTS = 'true'
63
+ const lookup = fakeLookup([{ address: '10.0.0.5', family: 4 }])
64
+ const result = await resolveSafeHostAddress('mail.internal.lan', { lookup })
65
+ expect(result).toEqual({ host: 'mail.internal.lan' })
66
+ expect(lookup).not.toHaveBeenCalled()
67
+ })
68
+ })
@@ -0,0 +1,180 @@
1
+ import {
2
+ credentialsToConnection,
3
+ getImapClient,
4
+ pickSentMailbox,
5
+ setImapClient,
6
+ type ImapConnectionOptions,
7
+ } from '../imap-client'
8
+
9
+ const mockFetch = jest.fn((_range: string, _query: unknown, _options?: unknown) =>
10
+ (async function* () {
11
+ yield {
12
+ uid: 150,
13
+ source: Buffer.from('raw mime'),
14
+ internalDate: new Date('2026-01-01T00:00:00Z'),
15
+ flags: ['\\Seen'],
16
+ }
17
+ })(),
18
+ )
19
+
20
+ let lastImapFlowOptions: Record<string, unknown> | undefined
21
+ let fakeMailboxes: Array<{ path?: string; specialUse?: string }> = []
22
+ const appendSpy = jest.fn(async (_mailbox: string, _message: Buffer, _flags?: string[]) => undefined)
23
+
24
+ // Connect-time SSRF pinning resolves the host via node:dns. Stub it to a fixed
25
+ // public IP so these tests stay offline and deterministic.
26
+ jest.mock('node:dns/promises', () => ({
27
+ lookup: jest.fn(async (_host: string) => [{ address: '93.184.216.34', family: 4 }]),
28
+ }))
29
+
30
+ jest.mock('imapflow', () => {
31
+ class FakeImapFlow {
32
+ secureConnection = true
33
+ constructor(options: Record<string, unknown>) {
34
+ lastImapFlowOptions = options
35
+ }
36
+ on() {}
37
+ async connect() {}
38
+ async logout() {}
39
+ async getMailboxLock() {
40
+ return { release() {} }
41
+ }
42
+ fetch(range: string, query: unknown, options?: unknown) {
43
+ return mockFetch(range, query, options)
44
+ }
45
+ async list() {
46
+ return fakeMailboxes
47
+ }
48
+ async append(mailbox: string, message: Buffer, flags?: string[]) {
49
+ return appendSpy(mailbox, message, flags)
50
+ }
51
+ }
52
+ return { ImapFlow: FakeImapFlow }
53
+ })
54
+
55
+ const connection: ImapConnectionOptions = {
56
+ host: 'imap.example.com',
57
+ port: 993,
58
+ user: 'user@example.com',
59
+ pass: 'secret',
60
+ transport: 'tls',
61
+ }
62
+
63
+ describe('ImapflowClient.fetchUidRange — UID range mode', () => {
64
+ beforeEach(() => {
65
+ mockFetch.mockClear()
66
+ // Force getImapClient() to construct the real ImapflowClient over the mocked imapflow.
67
+ setImapClient(null)
68
+ })
69
+ afterAll(() => setImapClient(null))
70
+
71
+ it('passes { uid: true } as FetchOptions so the range is read as UIDs, not sequence numbers', async () => {
72
+ const client = getImapClient()
73
+ const result = await client.fetchUidRange(connection, '61978:*', {})
74
+
75
+ expect(mockFetch).toHaveBeenCalledTimes(1)
76
+ const [range, query, options] = mockFetch.mock.calls[0]
77
+ expect(range).toBe('61978:*')
78
+ expect(query).toMatchObject({ source: true })
79
+ // Regression guard for the silent inbound-drop bug: a sequence-number range
80
+ // "n:*" collapses to the single newest message, so each poll fetched only the
81
+ // latest mail and skipped everything else. The third arg MUST request UID-mode.
82
+ expect(options).toEqual({ uid: true })
83
+
84
+ expect(result).toHaveLength(1)
85
+ expect(result[0].uid).toBe(150)
86
+ })
87
+ })
88
+
89
+ describe('pickSentMailbox — server Sent-folder discovery', () => {
90
+ it('returns the \\Sent special-use mailbox path', () => {
91
+ expect(
92
+ pickSentMailbox([{ path: 'INBOX' }, { path: '[Gmail]/Sent Mail', specialUse: '\\Sent' }]),
93
+ ).toBe('[Gmail]/Sent Mail')
94
+ })
95
+
96
+ it('returns a localized special-use path (not the English "Sent")', () => {
97
+ expect(pickSentMailbox([{ path: 'Wysłane', specialUse: '\\Sent' }])).toBe('Wysłane')
98
+ })
99
+
100
+ it('falls back to "Sent" when no \\Sent mailbox exists', () => {
101
+ expect(pickSentMailbox([{ path: 'INBOX' }, { path: 'Drafts', specialUse: '\\Drafts' }])).toBe(
102
+ 'Sent',
103
+ )
104
+ })
105
+
106
+ it('falls back to "Sent" for an empty or nullish listing', () => {
107
+ expect(pickSentMailbox([])).toBe('Sent')
108
+ expect(pickSentMailbox(null)).toBe('Sent')
109
+ expect(pickSentMailbox(undefined)).toBe('Sent')
110
+ })
111
+ })
112
+
113
+ describe('ImapflowClient.appendSent — targets the discovered Sent folder', () => {
114
+ beforeEach(() => {
115
+ setImapClient(null)
116
+ appendSpy.mockClear()
117
+ })
118
+ afterAll(() => setImapClient(null))
119
+
120
+ it('appends to the server-advertised \\Sent folder, not a hardcoded "Sent"', async () => {
121
+ fakeMailboxes = [{ path: 'INBOX' }, { path: '[Gmail]/Sent Mail', specialUse: '\\Sent' }]
122
+ await getImapClient().appendSent(connection, Buffer.from('raw mime'))
123
+ expect(appendSpy).toHaveBeenCalledWith('[Gmail]/Sent Mail', expect.any(Buffer), ['\\Seen'])
124
+ })
125
+
126
+ it('falls back to "Sent" when the server exposes no \\Sent special-use mailbox', async () => {
127
+ fakeMailboxes = [{ path: 'INBOX' }]
128
+ await getImapClient().appendSent(connection, Buffer.from('raw mime'))
129
+ expect(appendSpy).toHaveBeenCalledWith('Sent', expect.any(Buffer), ['\\Seen'])
130
+ })
131
+ })
132
+
133
+ describe('ImapflowClient.openConnection — SSRF host pinning', () => {
134
+ beforeEach(() => {
135
+ setImapClient(null)
136
+ lastImapFlowOptions = undefined
137
+ fakeMailboxes = []
138
+ })
139
+ afterAll(() => setImapClient(null))
140
+
141
+ it('connects to the resolved IP while keeping the hostname as the TLS servername', async () => {
142
+ await getImapClient().selectInbox(connection)
143
+ expect(lastImapFlowOptions?.host).toBe('93.184.216.34')
144
+ expect(lastImapFlowOptions?.tls).toMatchObject({
145
+ rejectUnauthorized: true,
146
+ servername: 'imap.example.com',
147
+ })
148
+ })
149
+ })
150
+
151
+ describe('credentialsToConnection — socket timeout override', () => {
152
+ const baseCredentials = {
153
+ imapHost: 'imap.example.com',
154
+ imapPort: 993,
155
+ imapTls: 'tls',
156
+ imapUser: 'alice@example.com',
157
+ imapPassword: 'secret',
158
+ } as unknown as Parameters<typeof credentialsToConnection>[0]
159
+
160
+ afterEach(() => {
161
+ delete process.env.OM_CHANNEL_IMAP_SOCKET_TIMEOUT_MS
162
+ })
163
+
164
+ it('omits timeoutMs (client falls back to its 60s default) when the env var is unset', () => {
165
+ delete process.env.OM_CHANNEL_IMAP_SOCKET_TIMEOUT_MS
166
+ expect(credentialsToConnection(baseCredentials).timeoutMs).toBeUndefined()
167
+ })
168
+
169
+ it('reads a positive OM_CHANNEL_IMAP_SOCKET_TIMEOUT_MS override', () => {
170
+ process.env.OM_CHANNEL_IMAP_SOCKET_TIMEOUT_MS = '90000'
171
+ expect(credentialsToConnection(baseCredentials).timeoutMs).toBe(90000)
172
+ })
173
+
174
+ it('ignores a non-numeric or non-positive override', () => {
175
+ process.env.OM_CHANNEL_IMAP_SOCKET_TIMEOUT_MS = 'abc'
176
+ expect(credentialsToConnection(baseCredentials).timeoutMs).toBeUndefined()
177
+ process.env.OM_CHANNEL_IMAP_SOCKET_TIMEOUT_MS = '0'
178
+ expect(credentialsToConnection(baseCredentials).timeoutMs).toBeUndefined()
179
+ })
180
+ })
@@ -0,0 +1,126 @@
1
+ import { normalizeInboundImapMessage } from '../normalize-inbound'
2
+
3
+ function buildMimeMessage(parts: {
4
+ messageId?: string
5
+ inReplyTo?: string
6
+ references?: string
7
+ subject?: string
8
+ from?: string
9
+ to?: string
10
+ date?: string
11
+ text?: string
12
+ html?: string
13
+ }): Buffer {
14
+ const headers: string[] = []
15
+ if (parts.messageId) headers.push(`Message-ID: ${parts.messageId}`)
16
+ if (parts.inReplyTo) headers.push(`In-Reply-To: ${parts.inReplyTo}`)
17
+ if (parts.references) headers.push(`References: ${parts.references}`)
18
+ if (parts.subject) headers.push(`Subject: ${parts.subject}`)
19
+ if (parts.from) headers.push(`From: ${parts.from}`)
20
+ if (parts.to) headers.push(`To: ${parts.to}`)
21
+ if (parts.date) headers.push(`Date: ${parts.date}`)
22
+ headers.push('MIME-Version: 1.0')
23
+ if (parts.html && parts.text) {
24
+ headers.push('Content-Type: multipart/alternative; boundary="alt-boundary"')
25
+ const body = [
26
+ '',
27
+ '--alt-boundary',
28
+ 'Content-Type: text/plain; charset=utf-8',
29
+ '',
30
+ parts.text,
31
+ '--alt-boundary',
32
+ 'Content-Type: text/html; charset=utf-8',
33
+ '',
34
+ parts.html,
35
+ '--alt-boundary--',
36
+ '',
37
+ ].join('\r\n')
38
+ return Buffer.from(headers.join('\r\n') + body, 'utf-8')
39
+ }
40
+ if (parts.html) {
41
+ headers.push('Content-Type: text/html; charset=utf-8')
42
+ return Buffer.from(headers.join('\r\n') + '\r\n\r\n' + parts.html, 'utf-8')
43
+ }
44
+ headers.push('Content-Type: text/plain; charset=utf-8')
45
+ return Buffer.from(headers.join('\r\n') + '\r\n\r\n' + (parts.text ?? ''), 'utf-8')
46
+ }
47
+
48
+ describe('normalizeInboundImapMessage', () => {
49
+ it('uses the Message-ID header as externalMessageId', async () => {
50
+ const result = await normalizeInboundImapMessage({
51
+ rawMessage: buildMimeMessage({
52
+ messageId: '<root@example.com>',
53
+ from: '"Alice" <alice@example.com>',
54
+ to: 'bob@example.com',
55
+ subject: 'Greetings',
56
+ text: 'Hello world',
57
+ date: 'Wed, 21 May 2026 10:00:00 +0000',
58
+ }),
59
+ uid: 42,
60
+ accountIdentifier: 'bob@example.com',
61
+ })
62
+ expect(result.externalMessageId).toBe('root@example.com')
63
+ expect(result.externalConversationId).toBe('root@example.com')
64
+ expect(result.senderIdentifier).toBe('alice@example.com')
65
+ expect(result.senderDisplayName).toBe('Alice')
66
+ expect(result.subject).toBe('Greetings')
67
+ expect(result.body).toContain('Hello world')
68
+ expect(result.bodyFormat).toBe('text')
69
+ expect(result.channelContentType).toBe('email/mime')
70
+ })
71
+
72
+ it('synthesises a deterministic fallback message id when missing', async () => {
73
+ const result = await normalizeInboundImapMessage({
74
+ rawMessage: buildMimeMessage({
75
+ from: 'eve@example.com',
76
+ to: 'bob@example.com',
77
+ subject: 'no id',
78
+ text: 'hi',
79
+ }),
80
+ uid: 7,
81
+ accountIdentifier: 'bob@example.com',
82
+ })
83
+ expect(result.externalMessageId).toBe('imap:7@bob@example.com')
84
+ expect(result.externalConversationId).toBe('imap:7@bob@example.com')
85
+ })
86
+
87
+ it('threads replies via In-Reply-To and root References', async () => {
88
+ const result = await normalizeInboundImapMessage({
89
+ rawMessage: buildMimeMessage({
90
+ messageId: '<reply@example.com>',
91
+ inReplyTo: '<parent@example.com>',
92
+ references: '<root@example.com> <parent@example.com>',
93
+ from: 'alice@example.com',
94
+ to: 'bob@example.com',
95
+ subject: 'Re: original',
96
+ text: 'replying',
97
+ }),
98
+ uid: 100,
99
+ accountIdentifier: 'bob@example.com',
100
+ })
101
+ expect(result.externalMessageId).toBe('reply@example.com')
102
+ expect(result.replyToExternalId).toBe('parent@example.com')
103
+ expect(result.externalConversationId).toBe('root@example.com')
104
+ expect((result.channelMetadata as { references: string[] }).references).toEqual([
105
+ 'root@example.com',
106
+ 'parent@example.com',
107
+ ])
108
+ })
109
+
110
+ it('prefers html body when both html and text are present', async () => {
111
+ const result = await normalizeInboundImapMessage({
112
+ rawMessage: buildMimeMessage({
113
+ messageId: '<html@example.com>',
114
+ from: 'alice@example.com',
115
+ to: 'bob@example.com',
116
+ subject: 'rich',
117
+ text: 'plain',
118
+ html: '<p><b>rich</b></p>',
119
+ }),
120
+ uid: 1,
121
+ accountIdentifier: 'bob@example.com',
122
+ })
123
+ expect(result.bodyFormat).toBe('html')
124
+ expect(result.body).toContain('<b>rich</b>')
125
+ })
126
+ })
@@ -0,0 +1,68 @@
1
+ import type { ImapCredentials } from '../credentials'
2
+ import { credentialsToConnection } from '../imap-client'
3
+ import { credentialsToSmtpConnection } from '../smtp-client'
4
+ import { assertTransportAllowed } from '../transport'
5
+
6
+ const baseCredentials: ImapCredentials = {
7
+ imapHost: 'imap.example.com',
8
+ imapPort: 993,
9
+ imapTls: 'tls',
10
+ imapUser: 'alice@example.com',
11
+ imapPassword: 'secret',
12
+ smtpHost: 'smtp.example.com',
13
+ smtpPort: 465,
14
+ smtpTls: 'tls',
15
+ smtpUser: 'alice@example.com',
16
+ smtpPassword: 'secret',
17
+ fromAddress: 'alice@example.com',
18
+ }
19
+
20
+ afterEach(() => {
21
+ delete process.env.OM_CHANNEL_IMAP_ALLOW_INSECURE_TRANSPORT
22
+ })
23
+
24
+ describe('assertTransportAllowed', () => {
25
+ it('throws an [internal]-prefixed error when imapTls is none and the flag is unset', () => {
26
+ expect(() => assertTransportAllowed({ ...baseCredentials, imapTls: 'none' })).toThrow(/^\[internal\]/)
27
+ expect(() => assertTransportAllowed({ ...baseCredentials, imapTls: 'none' })).toThrow(/cleartext/i)
28
+ })
29
+
30
+ it('throws when smtpTls is none and the flag is unset', () => {
31
+ expect(() => assertTransportAllowed({ ...baseCredentials, smtpTls: 'none' })).toThrow(/cleartext/i)
32
+ })
33
+
34
+ it('allows none transport when OM_CHANNEL_IMAP_ALLOW_INSECURE_TRANSPORT is truthy', () => {
35
+ process.env.OM_CHANNEL_IMAP_ALLOW_INSECURE_TRANSPORT = 'true'
36
+ expect(() => assertTransportAllowed({ ...baseCredentials, imapTls: 'none', smtpTls: 'none' })).not.toThrow()
37
+ })
38
+
39
+ it('always allows tls and starttls', () => {
40
+ expect(() => assertTransportAllowed({ ...baseCredentials, imapTls: 'tls', smtpTls: 'starttls' })).not.toThrow()
41
+ })
42
+ })
43
+
44
+ describe('credentialsToConnection (poll/send IMAP build) enforces transport', () => {
45
+ it('throws when imapTls is none and the flag is unset', () => {
46
+ expect(() => credentialsToConnection({ ...baseCredentials, imapTls: 'none' })).toThrow(/cleartext/i)
47
+ })
48
+
49
+ it('builds the connection when the flag is set', () => {
50
+ process.env.OM_CHANNEL_IMAP_ALLOW_INSECURE_TRANSPORT = 'true'
51
+ const connection = credentialsToConnection({ ...baseCredentials, imapTls: 'none' })
52
+ expect(connection.transport).toBe('none')
53
+ expect(connection.host).toBe('imap.example.com')
54
+ })
55
+ })
56
+
57
+ describe('credentialsToSmtpConnection (send SMTP build) enforces transport', () => {
58
+ it('throws when smtpTls is none and the flag is unset', () => {
59
+ expect(() => credentialsToSmtpConnection({ ...baseCredentials, smtpTls: 'none' })).toThrow(/cleartext/i)
60
+ })
61
+
62
+ it('builds the connection when the flag is set', () => {
63
+ process.env.OM_CHANNEL_IMAP_ALLOW_INSECURE_TRANSPORT = 'true'
64
+ const connection = credentialsToSmtpConnection({ ...baseCredentials, smtpTls: 'none' })
65
+ expect(connection.transport).toBe('none')
66
+ expect(connection.host).toBe('smtp.example.com')
67
+ })
68
+ })