@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,34 @@
1
+ /** @type {import('jest').Config} */
2
+ const base = require('../../jest.config.base.cjs')
3
+
4
+ module.exports = {
5
+ ...base,
6
+ testEnvironment: 'node',
7
+ watchman: false,
8
+ rootDir: '.',
9
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
10
+ moduleNameMapper: {
11
+ '^@open-mercato/channel-imap/(.*)$': '<rootDir>/src/$1',
12
+ '^@open-mercato/core/(.*)$': '<rootDir>/../core/src/$1',
13
+ '^@open-mercato/shared/(.*)$': '<rootDir>/../shared/src/$1',
14
+ '^@open-mercato/queue/(.*)$': '<rootDir>/../queue/src/$1',
15
+ '^@open-mercato/ui/(.*)$': '<rootDir>/../ui/src/$1',
16
+ },
17
+ transform: {
18
+ '^.+\\.(t|j)sx?$': [
19
+ '<rootDir>/../../scripts/jest-mikroorm-transformer.cjs',
20
+ {
21
+ tsconfig: {
22
+ jsx: 'react-jsx',
23
+ rootDir: '.',
24
+ ignoreDeprecations: '6.0',
25
+ },
26
+ },
27
+ ],
28
+ },
29
+ transformIgnorePatterns: [
30
+ 'node_modules/(?!(@mikro-orm|kysely)/)',
31
+ ],
32
+ testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
33
+ passWithNoTests: true,
34
+ }
package/package.json ADDED
@@ -0,0 +1,99 @@
1
+ {
2
+ "name": "@open-mercato/channel-imap",
3
+ "version": "0.6.4",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "scripts": {
7
+ "build": "node build.mjs",
8
+ "watch": "node watch.mjs",
9
+ "test": "jest --config jest.config.cjs",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "exports": {
13
+ ".": "./dist/index.js",
14
+ "./*.ts": {
15
+ "types": "./src/*.ts",
16
+ "default": "./dist/*.js"
17
+ },
18
+ "./*.tsx": {
19
+ "types": "./src/*.tsx",
20
+ "default": "./dist/*.js"
21
+ },
22
+ "./*.json": "./src/*.json",
23
+ "./*": {
24
+ "types": [
25
+ "./src/*.ts",
26
+ "./src/*.tsx"
27
+ ],
28
+ "default": "./dist/*.js"
29
+ },
30
+ "./*/*.json": "./src/*/*.json",
31
+ "./*/*": {
32
+ "types": [
33
+ "./src/*/*.ts",
34
+ "./src/*/*.tsx"
35
+ ],
36
+ "default": "./dist/*/*.js"
37
+ },
38
+ "./*/*/*.json": "./src/*/*/*.json",
39
+ "./*/*/*": {
40
+ "types": [
41
+ "./src/*/*/*.ts",
42
+ "./src/*/*/*.tsx"
43
+ ],
44
+ "default": "./dist/*/*/*.js"
45
+ },
46
+ "./*/*/*/*.json": "./src/*/*/*/*.json",
47
+ "./*/*/*/*": {
48
+ "types": [
49
+ "./src/*/*/*/*.ts",
50
+ "./src/*/*/*/*.tsx"
51
+ ],
52
+ "default": "./dist/*/*/*/*.js"
53
+ },
54
+ "./*/*/*/*/*.json": "./src/*/*/*/*/*.json",
55
+ "./*/*/*/*/*": {
56
+ "types": [
57
+ "./src/*/*/*/*/*.ts",
58
+ "./src/*/*/*/*/*.tsx"
59
+ ],
60
+ "default": "./dist/*/*/*/*/*.js"
61
+ }
62
+ },
63
+ "dependencies": {
64
+ "@open-mercato/core": "0.6.4",
65
+ "@open-mercato/queue": "0.6.4",
66
+ "@open-mercato/ui": "0.6.4",
67
+ "@types/mailparser": "^3.4.5",
68
+ "@types/nodemailer": "^8.0.0",
69
+ "imapflow": "^1.0.171",
70
+ "mailparser": "^3.7.1",
71
+ "nodemailer": "^8.0.10"
72
+ },
73
+ "peerDependencies": {
74
+ "@mikro-orm/postgresql": "^7.0.14",
75
+ "@open-mercato/shared": "0.6.4",
76
+ "react": "^19.0.0",
77
+ "react-dom": "^19.0.0"
78
+ },
79
+ "devDependencies": {
80
+ "@open-mercato/shared": "0.6.4",
81
+ "@types/jest": "^30.0.0",
82
+ "@types/react": "^19.2.17",
83
+ "@types/react-dom": "^19.2.3",
84
+ "esbuild": "^0.28.0",
85
+ "glob": "^13.0.6",
86
+ "jest": "^30.4.2",
87
+ "react": "19.2.7",
88
+ "react-dom": "19.2.7",
89
+ "ts-jest": "^29.4.11"
90
+ },
91
+ "publishConfig": {
92
+ "access": "public"
93
+ },
94
+ "repository": {
95
+ "type": "git",
96
+ "url": "https://github.com/open-mercato/open-mercato",
97
+ "directory": "packages/channel-imap"
98
+ }
99
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { metadata } from './modules/channel_imap/index'
@@ -0,0 +1,80 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { getAuthToken } from '@open-mercato/core/helpers/integration/authFixtures'
3
+ import { apiRequest } from '@open-mercato/core/helpers/integration/api'
4
+ import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
5
+
6
+ /**
7
+ * TC-CHANNEL-EMAIL-001 — IMAP provider visible to the hub's per-user channel API.
8
+ *
9
+ * After Slice 3e registers the `imap` adapter inside `@open-mercato/channel-imap`,
10
+ * the per-user channel routes must accept `providerKey: 'imap'` as a known provider.
11
+ * Without a live IMAP test server we exercise the routing-and-validation surface only;
12
+ * actual credential validation against a real IMAP host is covered by the unit tests
13
+ * in `lib/__tests__/validate-credentials.test.ts`.
14
+ *
15
+ * The route should NOT 404 the provider (proves registration), and should NOT 500
16
+ * (proves the request reaches the adapter and rejects cleanly when no live server
17
+ * is reachable).
18
+ */
19
+ test.describe('TC-CHANNEL-EMAIL-001: IMAP provider registration', () => {
20
+ test('POST connect/credentials with providerKey=imap reaches the adapter', async ({ request }) => {
21
+ const token = await getAuthToken(request)
22
+ const response = await apiRequest(
23
+ request,
24
+ 'POST',
25
+ '/api/communication_channels/channels/connect/credentials',
26
+ {
27
+ token,
28
+ data: {
29
+ providerKey: 'imap',
30
+ displayName: 'IMAP — integration test',
31
+ credentials: {
32
+ imapHost: 'invalid.test.example',
33
+ imapPort: 993,
34
+ imapTls: 'tls',
35
+ imapUser: 'fake@example.test',
36
+ imapPassword: 'wrong-password',
37
+ smtpHost: 'invalid.test.example',
38
+ smtpPort: 465,
39
+ smtpTls: 'tls',
40
+ smtpUser: 'fake@example.test',
41
+ smtpPassword: 'wrong-password',
42
+ fromAddress: 'fake@example.test',
43
+ },
44
+ },
45
+ },
46
+ )
47
+ expect(response.status(), 'route should not 5xx').toBeLessThan(500)
48
+ // 401 (no auth seeded) / 422 (validation failure surfaced via createCrudFormError) /
49
+ // 502 ish — but never 404, which would indicate the provider isn't registered.
50
+ expect(response.status(), 'IMAP provider should be registered').not.toBe(404)
51
+ })
52
+
53
+ test('POST connect/credentials with providerKey=imap and malformed credentials returns 422', async ({ request }) => {
54
+ const token = await getAuthToken(request)
55
+ const response = await apiRequest(
56
+ request,
57
+ 'POST',
58
+ '/api/communication_channels/channels/connect/credentials',
59
+ {
60
+ token,
61
+ data: {
62
+ providerKey: 'imap',
63
+ displayName: 'IMAP — malformed',
64
+ credentials: {
65
+ // Missing required imap/smtp fields entirely.
66
+ fromAddress: 'not-an-email',
67
+ },
68
+ },
69
+ },
70
+ )
71
+ expect(response.status()).toBeLessThan(500)
72
+ expect([401, 422, 400]).toContain(response.status())
73
+ if (response.status() === 422) {
74
+ // The route surfaces credential-validation failures as { error, fieldErrors }
75
+ // (see api/post/channels/connect/credentials/route.ts).
76
+ const body = await readJsonSafe<{ error?: string; fieldErrors?: Record<string, string> }>(response)
77
+ expect(body?.fieldErrors ?? body?.error).toBeTruthy()
78
+ }
79
+ })
80
+ })
@@ -0,0 +1,28 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { getAuthToken } from '@open-mercato/core/helpers/integration/authFixtures'
3
+ import { apiRequest } from '@open-mercato/core/helpers/integration/api'
4
+
5
+ /**
6
+ * TC-CHANNEL-EMAIL-002 — IMAP webhook endpoint stays disabled.
7
+ *
8
+ * IMAP has no webhook flow; the polling worker drives inbound. We still expose
9
+ * the standard `/api/communication_channels/webhook/[provider]` URL because the
10
+ * hub's route is generic. The IMAP adapter's `verifyWebhook` returns an
11
+ * `eventType: 'other'` event so the route MUST respond 2xx (not handled) instead
12
+ * of 5xx-ing or 404-ing.
13
+ */
14
+ test.describe('TC-CHANNEL-EMAIL-002: IMAP webhook is a no-op', () => {
15
+ test('POST /api/communication_channels/webhook/imap does not 5xx', async ({ request }) => {
16
+ const token = await getAuthToken(request)
17
+ const response = await apiRequest(
18
+ request,
19
+ 'POST',
20
+ '/api/communication_channels/webhook/imap',
21
+ {
22
+ token,
23
+ data: { ping: true },
24
+ },
25
+ )
26
+ expect(response.status(), 'webhook route should not 5xx').toBeLessThan(500)
27
+ })
28
+ })
@@ -0,0 +1,23 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { getAuthToken } from '@open-mercato/core/helpers/integration/authFixtures'
3
+ import { apiRequest } from '@open-mercato/core/helpers/integration/api'
4
+
5
+ /**
6
+ * TC-CHANNEL-EMAIL-003 — Profile page renders with the IMAP provider installed.
7
+ *
8
+ * Confirms `@open-mercato/channel-imap` does not break the per-user profile page
9
+ * (`/backend/profile/communication-channels`). Empty-state still renders even
10
+ * when the IMAP provider is the only one registered.
11
+ */
12
+ test.describe('TC-CHANNEL-EMAIL-003: profile page with IMAP provider installed', () => {
13
+ test('GET /backend/profile/communication-channels does not 5xx', async ({ request }) => {
14
+ const token = await getAuthToken(request)
15
+ const response = await apiRequest(
16
+ request,
17
+ 'GET',
18
+ '/backend/profile/communication-channels',
19
+ { token },
20
+ )
21
+ expect(response.status(), 'profile page should not 5xx').toBeLessThan(500)
22
+ })
23
+ })
@@ -0,0 +1,40 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { getAuthToken } from '@open-mercato/core/helpers/integration/authFixtures'
3
+ import { apiRequest } from '@open-mercato/core/helpers/integration/api'
4
+
5
+ /**
6
+ * TC-CHANNEL-EMAIL-021 — IMAP zero-history bootstrap
7
+ *
8
+ * Verifies that the polling worker surface exists and that the
9
+ * `/poll-now` operator endpoint is reachable. The full happy path —
10
+ * a freshly-connected channel persists `UIDVALIDITY` + `UIDNEXT` and
11
+ * fetches ZERO historical messages — is captured in the QA scenario
12
+ * markdown `TC-CHANNEL-EMAIL-021-imap-bootstrap.md`.
13
+ *
14
+ * Unit-level coverage of the bootstrap branch lives in
15
+ * `packages/channel-imap/.../lib/__tests__/adapter.test.ts`
16
+ * (`fetchHistory` describe block, "bootstrap" case).
17
+ */
18
+ test.describe('TC-CHANNEL-EMAIL-021: IMAP bootstrap', () => {
19
+ test('poll-now endpoint requires authentication', async ({ request }) => {
20
+ const response = await apiRequest(
21
+ request,
22
+ 'POST',
23
+ '/api/communication_channels/channels/00000000-0000-4000-8000-000000000021/poll-now',
24
+ // Intentionally empty token — this assertion is the 401 unauth path.
25
+ { token: '' },
26
+ )
27
+ expect(response.status()).toBe(401)
28
+ })
29
+
30
+ test('poll-now endpoint returns 400 for invalid UUID', async ({ request }) => {
31
+ const token = await getAuthToken(request)
32
+ const response = await apiRequest(
33
+ request,
34
+ 'POST',
35
+ '/api/communication_channels/channels/not-a-uuid/poll-now',
36
+ { token },
37
+ )
38
+ expect(response.status()).toBe(400)
39
+ })
40
+ })
@@ -0,0 +1,38 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { getAuthToken } from '@open-mercato/core/helpers/integration/authFixtures'
3
+ import { apiRequest } from '@open-mercato/core/helpers/integration/api'
4
+
5
+ /**
6
+ * TC-CHANNEL-EMAIL-022 — IMAP incremental ingest within 90s
7
+ *
8
+ * Verifies the channels-list endpoint is reachable (which proves the
9
+ * poll-tick scheduler + per-channel polling pipeline is wired). Full
10
+ * E2E (new mail arrives → polling worker picks it up within 60s tick →
11
+ * ingest-inbound-message creates a CRM interaction within 90s) is in
12
+ * the QA scenario markdown `TC-CHANNEL-EMAIL-022-imap-incremental.md`.
13
+ */
14
+ test.describe('TC-CHANNEL-EMAIL-022: IMAP incremental polling', () => {
15
+ test('me/channels endpoint requires authentication', async ({ request }) => {
16
+ // Intentionally empty token — this assertion is the 401 unauth path.
17
+ const response = await apiRequest(
18
+ request,
19
+ 'GET',
20
+ '/api/communication_channels/me/channels',
21
+ { token: '' },
22
+ )
23
+ expect(response.status()).toBe(401)
24
+ })
25
+
26
+ test('me/channels returns items array when authenticated', async ({ request }) => {
27
+ const token = await getAuthToken(request)
28
+ const response = await apiRequest(
29
+ request,
30
+ 'GET',
31
+ '/api/communication_channels/me/channels',
32
+ { token },
33
+ )
34
+ expect(response.status()).toBe(200)
35
+ const body = (await response.json()) as { items?: unknown }
36
+ expect(Array.isArray(body.items)).toBe(true)
37
+ })
38
+ })
@@ -0,0 +1,31 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { apiRequest } from '@open-mercato/core/helpers/integration/api'
3
+
4
+ /**
5
+ * TC-CHANNEL-EMAIL-023 — Threading via References token
6
+ *
7
+ * Smoke: the layered thread matcher prefers `token-references`
8
+ * (highest-confidence strategy) when an inbound message carries our
9
+ * synthetic `<om_TOKEN@open-mercato.invalid>` Message-ID in
10
+ * `References`. The 5-strategy fallthrough order is unit-tested in
11
+ * `packages/core/.../lib/__tests__/thread-matcher.test.ts`; the
12
+ * end-to-end "send → reply preserves References → CRM threads back"
13
+ * path is in the QA scenario markdown.
14
+ *
15
+ * Webhook-less providers have no public endpoint to assert against,
16
+ * so this smoke test is intentionally minimal — it just confirms the
17
+ * platform's send-as-user route is reachable (used by the outbound
18
+ * test setup in the QA scenario).
19
+ */
20
+ test.describe('TC-CHANNEL-EMAIL-023: References-token threading', () => {
21
+ test('send-as-user endpoint requires authentication', async ({ request }) => {
22
+ const response = await apiRequest(
23
+ request,
24
+ 'POST',
25
+ '/api/communication_channels/send-as-user',
26
+ // Intentionally empty token — this test asserts the 401 unauth path.
27
+ { token: '', data: { channelId: '00000000-0000-4000-8000-000000000023' } },
28
+ )
29
+ expect(response.status()).toBe(401)
30
+ })
31
+ })
@@ -0,0 +1,27 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { apiRequest } from '@open-mercato/core/helpers/integration/api'
3
+
4
+ /**
5
+ * TC-CHANNEL-EMAIL-024 — Threading via body footer
6
+ *
7
+ * When the recipient's MUA strips RFC5322 References on reply, our
8
+ * hidden body-footer marker (`<span style="display:none">[OM:TOKEN]</span>`
9
+ * for HTML; `[OM:TOKEN]` bracketed marker for plain text) still
10
+ * survives quoting and the layered matcher's `token-body` strategy
11
+ * threads the reply correctly.
12
+ *
13
+ * Full E2E in the QA scenario markdown. This smoke test confirms the
14
+ * send-as-user endpoint exists (same shape as TC-023).
15
+ */
16
+ test.describe('TC-CHANNEL-EMAIL-024: Body-footer threading', () => {
17
+ test('send-as-user endpoint exists at the expected path', async ({ request }) => {
18
+ const response = await apiRequest(
19
+ request,
20
+ 'POST',
21
+ '/api/communication_channels/send-as-user',
22
+ // Intentionally empty token — the 401 / 400 branch is what we assert.
23
+ { token: '', data: {} },
24
+ )
25
+ expect([400, 401]).toContain(response.status())
26
+ })
27
+ })
@@ -0,0 +1,23 @@
1
+ import { test } from '@playwright/test'
2
+
3
+ /**
4
+ * TC-CHANNEL-EMAIL-025 — JWZ-headers fallback threading
5
+ *
6
+ * Verifies the `jwz-headers` strategy (Spec B § thread-matcher,
7
+ * confidence: medium) — when an inbound reply has no `om_*` token
8
+ * but carries `In-Reply-To` / `References` pointing at one of our
9
+ * outbound `Message-Id`s, the matcher walks the conventional JWZ
10
+ * algorithm against the existing `external_messages` table.
11
+ *
12
+ * This path is exercised end-to-end by the QA scenario markdown
13
+ * `TC-CHANNEL-EMAIL-025-jwz-fallback.md`. The unit-level coverage
14
+ * lives in `packages/core/.../lib/__tests__/thread-matcher.test.ts`
15
+ * (Strategy 3 — jwz-headers describe block).
16
+ *
17
+ * No additional API surface to assert at the integration layer
18
+ * beyond the smoke tests already in TC-023/024 — this spec file
19
+ * documents the existence of the JWZ pathway for QA-tracking purposes.
20
+ */
21
+ test.describe('TC-CHANNEL-EMAIL-025: JWZ-headers fallback', () => {
22
+ test.skip('behavioral coverage: thread-matcher.test.ts Strategy 3 (jwz-headers). Playwright E2E is infeasible — provider mock seams are process-local (see TC-CHANNEL-EMAIL-031 / TC-CRM-EMAIL-001).', () => {})
23
+ })
@@ -0,0 +1,18 @@
1
+ import { test } from '@playwright/test'
2
+
3
+ /**
4
+ * TC-CHANNEL-EMAIL-026 — Subject + participants fallback threading
5
+ *
6
+ * Last-ditch matcher strategy: when neither `om_*` token nor JWZ
7
+ * headers are present, the matcher normalises the inbound subject
8
+ * (`Re:`, `Fwd:`, `[EXTERNAL]` stripped) and checks for an existing
9
+ * thread whose participants overlap >= 50% with the inbound's
10
+ * sender/to/cc. Confidence: low.
11
+ *
12
+ * Unit-tested in `packages/core/.../lib/__tests__/thread-matcher.test.ts`
13
+ * (Strategy 4 — subject-participants). End-to-end is the QA scenario
14
+ * markdown.
15
+ */
16
+ test.describe('TC-CHANNEL-EMAIL-026: Subject + participants fallback', () => {
17
+ test.skip('behavioral coverage: thread-matcher.test.ts Strategy 4 (subject + participants). Playwright E2E is infeasible — provider mock seams are process-local (see TC-CHANNEL-EMAIL-031 / TC-CRM-EMAIL-001).', () => {})
18
+ })
@@ -0,0 +1,18 @@
1
+ import { test } from '@playwright/test'
2
+
3
+ /**
4
+ * TC-CHANNEL-EMAIL-027 — Auto-recovery from `status='error'`
5
+ *
6
+ * Spec B § Phase B5 — `poll-tick.ts` enumerates two channel pools per
7
+ * tick: (a) `status='connected'` and due for polling, (b) `status='error'`
8
+ * whose `lastFailureAt` is older than `OM_CHANNEL_AUTO_RECOVER_MINUTES`
9
+ * (default 30). A successful poll in pool (b) flips status back to
10
+ * 'connected' via `poll-channel.ts`.
11
+ *
12
+ * Unit-tested in `packages/core/.../workers/__tests__/poll-tick.test.ts`
13
+ * (auto-recovery describe block). Full E2E (force a transient error →
14
+ * fast-forward 30 min → next tick recovers) is in the QA scenario.
15
+ */
16
+ test.describe('TC-CHANNEL-EMAIL-027: Auto-recovery sweep', () => {
17
+ test.skip('behavioral coverage: workers/__tests__/poll-tick.test.ts (auto-recovery sweep). Playwright E2E is infeasible — provider mock seams are process-local + needs scheduler fast-forward.', () => {})
18
+ })
@@ -0,0 +1,19 @@
1
+ import { test } from '@playwright/test'
2
+
3
+ /**
4
+ * TC-CHANNEL-EMAIL-028 — Malformed MIME → dead-letter, cursor advances
5
+ *
6
+ * Spec B § Phase B4 — `poll-channel.ts` classifies per-message ingest
7
+ * failures: transient failures abort the loop without advancing the
8
+ * cursor (idempotent retry on next tick), permanent failures write the
9
+ * raw MIME blob + error metadata to `ChannelIngestDeadLetter`
10
+ * (encrypted at rest via `defaultEncryptionMaps`) and the cursor
11
+ * advances anyway so the bad blob never re-stalls the channel.
12
+ *
13
+ * Unit-tested via the `ChannelIngestDeadLetter row shape (Spec B § B4)`
14
+ * describe in `ingest-inbound-message.test.ts` plus the implementation
15
+ * paths in `poll-channel.test.ts`. Full E2E in the QA scenario.
16
+ */
17
+ test.describe('TC-CHANNEL-EMAIL-028: Malformed MIME → dead-letter', () => {
18
+ test.skip('behavioral coverage: workers/__tests__/poll-channel.test.ts (permanent ingest failure → dead-letter + cursor advances; transient → cursor held). Playwright E2E is infeasible — provider mock seams are process-local.', () => {})
19
+ })
@@ -0,0 +1,72 @@
1
+ import { expect, test } from '@playwright/test'
2
+ import { getAuthToken } from '@open-mercato/core/helpers/integration/authFixtures'
3
+ import { apiRequest } from '@open-mercato/core/helpers/integration/api'
4
+
5
+ /**
6
+ * TC-CHANNEL-EMAIL-029 — operator-triggered backlog import (Spec B § Phase B6).
7
+ *
8
+ * Verifies that the `/api/communication_channels/channels/{id}/import-history`
9
+ * route is reachable, requires authentication, validates the body, and 404s
10
+ * for non-existent / not-owned channels (the access-control branch).
11
+ *
12
+ * Concurrency guard (429), the ProgressJob lifecycle, and the IMAP SEARCH +
13
+ * fetch loop are exercised by the unit-test layer:
14
+ * - packages/core/src/modules/communication_channels/commands/__tests__/queue-import-history.test.ts
15
+ * - packages/core/src/modules/communication_channels/workers/__tests__/channel-import-history.test.ts
16
+ * - packages/channel-imap/src/modules/channel_imap/lib/__tests__/adapter.test.ts (importHistory cases)
17
+ *
18
+ * The full end-to-end (connect a mailbox → POST /import-history → ProgressJob
19
+ * completes → imported messages visible on Person timeline) is captured in
20
+ * `.ai/qa/scenarios/TC-CHANNEL-EMAIL-029-import-history.md` for manual QA.
21
+ */
22
+ test.describe('TC-CHANNEL-EMAIL-029: import-history route wiring', () => {
23
+ const FAKE_CHANNEL_ID = '00000000-0000-0000-0000-000000000029'
24
+
25
+ test('rejects unauthenticated requests', async ({ request }) => {
26
+ const response = await apiRequest(
27
+ request,
28
+ 'POST',
29
+ `/api/communication_channels/channels/${FAKE_CHANNEL_ID}/import-history`,
30
+ // Intentionally empty token — this test asserts the 401 unauth path.
31
+ { token: '', data: { sinceDays: 14 } },
32
+ )
33
+ expect(response.status()).toBe(401)
34
+ })
35
+
36
+ test('returns 400 on invalid channel id', async ({ request }) => {
37
+ const token = await getAuthToken(request)
38
+ const response = await apiRequest(
39
+ request,
40
+ 'POST',
41
+ '/api/communication_channels/channels/not-a-uuid/import-history',
42
+ { token, data: { sinceDays: 14 } },
43
+ )
44
+ expect(response.status()).toBe(400)
45
+ })
46
+
47
+ test('returns 400 when body fails Zod validation (sinceDays out of range)', async ({ request }) => {
48
+ const token = await getAuthToken(request)
49
+ const response = await apiRequest(
50
+ request,
51
+ 'POST',
52
+ `/api/communication_channels/channels/${FAKE_CHANNEL_ID}/import-history`,
53
+ { token, data: { sinceDays: 9999 } },
54
+ )
55
+ expect(response.status()).toBe(400)
56
+ })
57
+
58
+ test('returns 404 for a channel the caller does not own', async ({ request }) => {
59
+ const token = await getAuthToken(request)
60
+ const response = await apiRequest(
61
+ request,
62
+ 'POST',
63
+ `/api/communication_channels/channels/${FAKE_CHANNEL_ID}/import-history`,
64
+ { token, data: { sinceDays: 14, maxMessages: 100 } },
65
+ )
66
+ // A caller without an organization scope is rejected with 400 ("No
67
+ // organization scope") before the channel lookup; a scoped caller reaches
68
+ // the channel-not-found branch (404, same shape as access-denied for parity).
69
+ expect(response.status(), 'route should not 5xx').toBeLessThan(500)
70
+ expect([400, 403, 404]).toContain(response.status())
71
+ })
72
+ })
@@ -0,0 +1,19 @@
1
+ import { test } from '@playwright/test'
2
+
3
+ /**
4
+ * TC-CHANNEL-EMAIL-030 — Sent-folder dedup
5
+ *
6
+ * Spec B § Phase B3 — `ingest-inbound-message.ts` short-circuits when
7
+ * the inbound message's `messageId` matches an outbound
8
+ * `MessageChannelLink.channelMetadata.messageId` we already sent. This
9
+ * prevents IMAP polls of the Sent folder from creating duplicate
10
+ * inbound rows for every outbound the user sent.
11
+ *
12
+ * Contract unit-tested in `ingest-inbound-message.test.ts` (sent-folder
13
+ * dedup describe block). Full E2E in the QA scenario markdown — send
14
+ * an outbound, observe Sent folder surfaces it, confirm no duplicate
15
+ * `MessageChannelLink` row appears for the inbound direction.
16
+ */
17
+ test.describe('TC-CHANNEL-EMAIL-030: Sent-folder dedup', () => {
18
+ test.skip('behavioral coverage: ingest-inbound-message.test.ts (sent-folder dedup contract + dedup → status=duplicate). Playwright E2E is infeasible — provider mock seams are process-local.', () => {})
19
+ })
@@ -0,0 +1,6 @@
1
+ export const features = [
2
+ { id: 'channel_imap.view', title: 'View IMAP channel configuration', module: 'channel_imap' },
3
+ { id: 'channel_imap.configure', title: 'Configure IMAP channel defaults', module: 'channel_imap' },
4
+ ]
5
+
6
+ export default features
@@ -0,0 +1,26 @@
1
+ import { asValue } from 'awilix'
2
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import {
4
+ hasChannelAdapter,
5
+ registerChannelAdapter,
6
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'
7
+ import { getImapChannelAdapter } from './lib/adapter'
8
+ import { channelImapHealthCheck } from './lib/health'
9
+
10
+ /**
11
+ * Re-register the adapter on container creation as a safety net for runtime
12
+ * environments that bypass module setup (worker-only nodes, ad-hoc CLI). The
13
+ * underlying registry is process-wide so the registration is idempotent.
14
+ */
15
+ export function register(container: AppContainer): void {
16
+ if (!hasChannelAdapter('imap')) {
17
+ registerChannelAdapter(getImapChannelAdapter())
18
+ }
19
+ container.register({
20
+ channelImapAdapter: asValue(getImapChannelAdapter()),
21
+ // Registered under the exact service name declared in `integration.ts`
22
+ // (`healthCheck.service`). Without this, the hub's `container.resolve(...)`
23
+ // throws and the channel reports permanently 'unhealthy'.
24
+ channelImapHealthCheck: asValue(channelImapHealthCheck),
25
+ })
26
+ }
@@ -0,0 +1,6 @@
1
+ export const metadata = {
2
+ id: 'channel_imap',
3
+ title: 'IMAP + SMTP Email Channel',
4
+ description:
5
+ 'Connect personal mailboxes via IMAP for inbound polling and SMTP for outbound delivery. Pairs with the Communications Hub (SPEC-045d).',
6
+ }