@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,98 @@
1
+ import type { ValidateCredentialsResult } from '@open-mercato/core/modules/communication_channels/lib/adapter'
2
+ import { imapCredentialsSchema } from './credentials'
3
+ import {
4
+ credentialsToConnection,
5
+ getImapClient,
6
+ } from './imap-client'
7
+ import {
8
+ credentialsToSmtpConnection,
9
+ getSmtpClient,
10
+ } from './smtp-client'
11
+ import { INSECURE_TRANSPORT_MESSAGE, isInsecureTransportAllowed } from './transport'
12
+
13
+ /**
14
+ * Validate IMAP+SMTP credentials by attempting a live LOGIN on both servers.
15
+ *
16
+ * Strategy:
17
+ * 1. Zod-parse the credential payload — returns shape errors first.
18
+ * 2. Open IMAP, capture capabilities, log out.
19
+ * 3. Run SMTP `verify` (extends EHLO, optional STARTTLS, AUTH LOGIN ping).
20
+ *
21
+ * Returns `{ ok: false, errors }` with field-level messages so the hub can pass
22
+ * them straight to `createCrudFormError` and the CrudForm inline-highlights the
23
+ * offending input. Returns `{ ok: true }` only when both servers accept the login.
24
+ */
25
+
26
+ export async function validateImapCredentials(
27
+ rawCredentials: unknown,
28
+ ): Promise<ValidateCredentialsResult> {
29
+ const parsed = imapCredentialsSchema.safeParse(rawCredentials)
30
+ if (!parsed.success) {
31
+ const errors: Record<string, string> = {}
32
+ for (const issue of parsed.error.issues) {
33
+ const path = issue.path[0]
34
+ if (typeof path !== 'string') continue
35
+ // First error wins per field — CrudForm only renders one per field anyway.
36
+ if (!errors[path]) errors[path] = issue.message
37
+ }
38
+ return { ok: false, errors }
39
+ }
40
+
41
+ const credentials = parsed.data
42
+
43
+ // Reject cleartext transport by default. The shared `transport` helper is the
44
+ // single source of truth for the policy (and enforces it again at connection
45
+ // build time for every op); here we surface it as field-level errors so the
46
+ // connect form can inline-highlight the offending TLS selector without
47
+ // touching the network.
48
+ if (!isInsecureTransportAllowed()) {
49
+ const insecureTransportErrors: Record<string, string> = {}
50
+ if (credentials.imapTls === 'none') insecureTransportErrors.imapTls = INSECURE_TRANSPORT_MESSAGE
51
+ if (credentials.smtpTls === 'none') insecureTransportErrors.smtpTls = INSECURE_TRANSPORT_MESSAGE
52
+ if (Object.keys(insecureTransportErrors).length > 0) {
53
+ return { ok: false, errors: insecureTransportErrors }
54
+ }
55
+ }
56
+
57
+ const imap = getImapClient()
58
+ const smtp = getSmtpClient()
59
+
60
+ try {
61
+ await imap.connectAndValidate(credentialsToConnection(credentials))
62
+ } catch (error) {
63
+ return {
64
+ ok: false,
65
+ errors: {
66
+ imapPassword: classifyAuthError(error, 'IMAP login failed.'),
67
+ },
68
+ }
69
+ }
70
+
71
+ try {
72
+ await smtp.verify(credentialsToSmtpConnection(credentials))
73
+ } catch (error) {
74
+ return {
75
+ ok: false,
76
+ errors: {
77
+ smtpPassword: classifyAuthError(error, 'SMTP login failed.'),
78
+ },
79
+ }
80
+ }
81
+
82
+ return { ok: true }
83
+ }
84
+
85
+ function classifyAuthError(error: unknown, fallback: string): string {
86
+ // Keep the coarse classification but never echo raw upstream server text
87
+ // (banners, internal hostnames) back to the client. Log the full original
88
+ // message server-side for diagnostics instead.
89
+ const message = error instanceof Error ? error.message : String(error ?? '')
90
+ console.warn('[internal] channel_imap credential validation failed:', message)
91
+ if (/auth|login|credentials|535|454|530/i.test(message)) {
92
+ return 'Authentication rejected by the server. Check the username and password.'
93
+ }
94
+ if (/timeout|ETIMEDOUT|ECONNREFUSED|ENOTFOUND|EAI_AGAIN/i.test(message)) {
95
+ return 'Could not reach the server. Check the host and port.'
96
+ }
97
+ return fallback
98
+ }
@@ -0,0 +1,34 @@
1
+ import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
2
+ import {
3
+ hasChannelAdapter,
4
+ registerChannelAdapter,
5
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'
6
+ import { getImapChannelAdapter } from './lib/adapter'
7
+
8
+ /**
9
+ * The IMAP provider registers its `ChannelAdapter` exactly once per process at
10
+ * import time. We guard with `hasChannelAdapter` so dev-mode HMR or repeated
11
+ * imports during tests don't throw the registry's "duplicate providerKey" error.
12
+ *
13
+ * No per-tenant onTenantCreated work is needed: IMAP credentials are connected
14
+ * by individual users via the `/backend/profile/communication-channels` page,
15
+ * not via tenant-bootstrap env presets.
16
+ */
17
+ function ensureImapAdapterRegistered(): void {
18
+ if (hasChannelAdapter('imap')) return
19
+ registerChannelAdapter(getImapChannelAdapter())
20
+ }
21
+
22
+ ensureImapAdapterRegistered()
23
+
24
+ export const setup: ModuleSetupConfig = {
25
+ defaultRoleFeatures: {
26
+ superadmin: ['channel_imap.view', 'channel_imap.configure'],
27
+ admin: ['channel_imap.view', 'channel_imap.configure'],
28
+ },
29
+ async onTenantCreated() {
30
+ ensureImapAdapterRegistered()
31
+ },
32
+ }
33
+
34
+ export default setup
@@ -0,0 +1,359 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
8
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
9
+ import { Button } from '@open-mercato/ui/primitives/button'
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogFooter,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ } from '@open-mercato/ui/primitives/dialog'
17
+ import { Input } from '@open-mercato/ui/primitives/input'
18
+ import { Label } from '@open-mercato/ui/primitives/label'
19
+ import { PasswordInput } from '@open-mercato/ui/primitives/password-input'
20
+ import {
21
+ Select,
22
+ SelectContent,
23
+ SelectItem,
24
+ SelectTrigger,
25
+ SelectValue,
26
+ } from '@open-mercato/ui/primitives/select'
27
+
28
+ type WidgetContext = Record<string, unknown> & {
29
+ reload?: () => void
30
+ }
31
+
32
+ type ConnectResponse = {
33
+ channelId?: string
34
+ error?: string
35
+ fieldErrors?: Record<string, string>
36
+ }
37
+
38
+ type TlsMode = 'tls' | 'starttls' | 'none'
39
+
40
+ type FormState = {
41
+ displayName: string
42
+ fromAddress: string
43
+ imapHost: string
44
+ imapPort: string
45
+ imapTls: TlsMode
46
+ imapUser: string
47
+ imapPassword: string
48
+ smtpHost: string
49
+ smtpPort: string
50
+ smtpTls: TlsMode
51
+ smtpUser: string
52
+ smtpPassword: string
53
+ }
54
+
55
+ const INITIAL_FORM: FormState = {
56
+ displayName: '',
57
+ fromAddress: '',
58
+ imapHost: '',
59
+ imapPort: '993',
60
+ imapTls: 'tls',
61
+ imapUser: '',
62
+ imapPassword: '',
63
+ smtpHost: '',
64
+ smtpPort: '465',
65
+ smtpTls: 'tls',
66
+ smtpUser: '',
67
+ smtpPassword: '',
68
+ }
69
+
70
+ export default function ConnectImapWidget({
71
+ context,
72
+ }: InjectionWidgetComponentProps<Record<string, unknown>, Record<string, unknown>>) {
73
+ const t = useT()
74
+ const widgetContext = context as WidgetContext | undefined
75
+ const [open, setOpen] = React.useState(false)
76
+ const [pending, setPending] = React.useState(false)
77
+ const [form, setForm] = React.useState<FormState>(INITIAL_FORM)
78
+ const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})
79
+ const { runMutation, retryLastMutation } = useGuardedMutation({
80
+ contextId: 'channel-imap-connect',
81
+ blockedMessage: t('communication_channels.profile.connect.blocked', 'Connection blocked by validation'),
82
+ })
83
+ const mutationContext = React.useMemo(
84
+ () => ({ providerKey: 'imap', retryLastMutation }),
85
+ [retryLastMutation],
86
+ )
87
+
88
+ const update = React.useCallback(
89
+ <K extends keyof FormState>(key: K, value: FormState[K]) => {
90
+ setForm((current) => ({ ...current, [key]: value }))
91
+ setFieldErrors((current) => {
92
+ if (!current[key]) return current
93
+ const next = { ...current }
94
+ delete next[key]
95
+ return next
96
+ })
97
+ },
98
+ [],
99
+ )
100
+
101
+ const submit = React.useCallback(async () => {
102
+ if (pending) return
103
+ setPending(true)
104
+ setFieldErrors({})
105
+ try {
106
+ const response = await runMutation({
107
+ context: mutationContext,
108
+ mutationPayload: { providerKey: 'imap', displayName: form.displayName },
109
+ operation: () =>
110
+ apiCall<ConnectResponse>('/api/communication_channels/channels/connect/credentials', {
111
+ method: 'POST',
112
+ headers: { 'content-type': 'application/json' },
113
+ body: JSON.stringify({
114
+ providerKey: 'imap',
115
+ displayName: form.displayName.trim() || form.fromAddress.trim(),
116
+ pollIntervalSeconds: 300,
117
+ credentials: {
118
+ imapHost: form.imapHost.trim(),
119
+ imapPort: Number(form.imapPort),
120
+ imapTls: form.imapTls,
121
+ imapUser: form.imapUser.trim(),
122
+ imapPassword: form.imapPassword,
123
+ smtpHost: form.smtpHost.trim(),
124
+ smtpPort: Number(form.smtpPort),
125
+ smtpTls: form.smtpTls,
126
+ smtpUser: (form.smtpUser.trim() || form.imapUser.trim()),
127
+ smtpPassword: form.smtpPassword || form.imapPassword,
128
+ fromAddress: form.fromAddress.trim(),
129
+ },
130
+ }),
131
+ }),
132
+ })
133
+ const body = response.result as ConnectResponse | undefined
134
+ if (!response.ok) {
135
+ setFieldErrors(body?.fieldErrors ?? {})
136
+ flash(
137
+ body?.error ??
138
+ t('communication_channels.profile.connect.credentialsFailed', 'Could not connect mailbox.'),
139
+ 'error',
140
+ )
141
+ return
142
+ }
143
+ flash(t('communication_channels.profile.connect.connected', 'Channel connected.'), 'success')
144
+ setOpen(false)
145
+ setForm(INITIAL_FORM)
146
+ widgetContext?.reload?.()
147
+ } finally {
148
+ setPending(false)
149
+ }
150
+ }, [form, mutationContext, pending, runMutation, t, widgetContext])
151
+
152
+ const onDialogKeyDown = React.useCallback(
153
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
154
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
155
+ event.preventDefault()
156
+ void submit()
157
+ }
158
+ },
159
+ [submit],
160
+ )
161
+
162
+ return (
163
+ <>
164
+ <Button type="button" variant="outline" onClick={() => setOpen(true)}>
165
+ {t('communication_channels.profile.connect.imap', 'Connect IMAP')}
166
+ </Button>
167
+ <Dialog open={open} onOpenChange={setOpen}>
168
+ <DialogContent onKeyDown={onDialogKeyDown}>
169
+ <DialogHeader>
170
+ <DialogTitle>
171
+ {t('communication_channels.profile.connect.imapTitle', 'Connect IMAP mailbox')}
172
+ </DialogTitle>
173
+ </DialogHeader>
174
+
175
+ <div className="grid gap-4 py-2">
176
+ <Field
177
+ label={t('communication_channels.profile.connect.fields.displayName', 'Display name')}
178
+ error={fieldErrors.displayName}
179
+ >
180
+ <Input
181
+ value={form.displayName}
182
+ onChange={(event) => update('displayName', event.target.value)}
183
+ aria-invalid={Boolean(fieldErrors.displayName)}
184
+ />
185
+ </Field>
186
+ <Field
187
+ label={t('communication_channels.profile.connect.fields.fromAddress', 'From address')}
188
+ error={fieldErrors.fromAddress}
189
+ >
190
+ <Input
191
+ type="email"
192
+ value={form.fromAddress}
193
+ onChange={(event) => update('fromAddress', event.target.value)}
194
+ aria-invalid={Boolean(fieldErrors.fromAddress)}
195
+ />
196
+ </Field>
197
+
198
+ <div className="grid gap-3 md:grid-cols-2">
199
+ <Field
200
+ label={t('communication_channels.profile.connect.fields.imapHost', 'IMAP host')}
201
+ error={fieldErrors.imapHost}
202
+ >
203
+ <Input
204
+ value={form.imapHost}
205
+ onChange={(event) => update('imapHost', event.target.value)}
206
+ aria-invalid={Boolean(fieldErrors.imapHost)}
207
+ />
208
+ </Field>
209
+ <Field
210
+ label={t('communication_channels.profile.connect.fields.imapPort', 'IMAP port')}
211
+ error={fieldErrors.imapPort}
212
+ >
213
+ <Input
214
+ inputMode="numeric"
215
+ value={form.imapPort}
216
+ onChange={(event) => update('imapPort', event.target.value)}
217
+ aria-invalid={Boolean(fieldErrors.imapPort)}
218
+ />
219
+ </Field>
220
+ </div>
221
+
222
+ <div className="grid gap-3 md:grid-cols-2">
223
+ <Field
224
+ label={t('communication_channels.profile.connect.fields.imapTls', 'IMAP security')}
225
+ error={fieldErrors.imapTls}
226
+ >
227
+ <TlsSelect value={form.imapTls} onChange={(value) => update('imapTls', value)} />
228
+ </Field>
229
+ <Field
230
+ label={t('communication_channels.profile.connect.fields.imapUser', 'IMAP username')}
231
+ error={fieldErrors.imapUser}
232
+ >
233
+ <Input
234
+ value={form.imapUser}
235
+ onChange={(event) => update('imapUser', event.target.value)}
236
+ aria-invalid={Boolean(fieldErrors.imapUser)}
237
+ />
238
+ </Field>
239
+ </div>
240
+
241
+ <Field
242
+ label={t('communication_channels.profile.connect.fields.imapPassword', 'IMAP password')}
243
+ error={fieldErrors.imapPassword}
244
+ >
245
+ <PasswordInput
246
+ value={form.imapPassword}
247
+ onChange={(event) => update('imapPassword', event.target.value)}
248
+ aria-invalid={Boolean(fieldErrors.imapPassword)}
249
+ />
250
+ </Field>
251
+
252
+ <div className="grid gap-3 md:grid-cols-2">
253
+ <Field
254
+ label={t('communication_channels.profile.connect.fields.smtpHost', 'SMTP host')}
255
+ error={fieldErrors.smtpHost}
256
+ >
257
+ <Input
258
+ value={form.smtpHost}
259
+ onChange={(event) => update('smtpHost', event.target.value)}
260
+ aria-invalid={Boolean(fieldErrors.smtpHost)}
261
+ />
262
+ </Field>
263
+ <Field
264
+ label={t('communication_channels.profile.connect.fields.smtpPort', 'SMTP port')}
265
+ error={fieldErrors.smtpPort}
266
+ >
267
+ <Input
268
+ inputMode="numeric"
269
+ value={form.smtpPort}
270
+ onChange={(event) => update('smtpPort', event.target.value)}
271
+ aria-invalid={Boolean(fieldErrors.smtpPort)}
272
+ />
273
+ </Field>
274
+ </div>
275
+
276
+ <div className="grid gap-3 md:grid-cols-2">
277
+ <Field
278
+ label={t('communication_channels.profile.connect.fields.smtpTls', 'SMTP security')}
279
+ error={fieldErrors.smtpTls}
280
+ >
281
+ <TlsSelect value={form.smtpTls} onChange={(value) => update('smtpTls', value)} />
282
+ </Field>
283
+ <Field
284
+ label={t('communication_channels.profile.connect.fields.smtpUser', 'SMTP username')}
285
+ error={fieldErrors.smtpUser}
286
+ >
287
+ <Input
288
+ value={form.smtpUser}
289
+ onChange={(event) => update('smtpUser', event.target.value)}
290
+ aria-invalid={Boolean(fieldErrors.smtpUser)}
291
+ />
292
+ </Field>
293
+ </div>
294
+
295
+ <Field
296
+ label={t('communication_channels.profile.connect.fields.smtpPassword', 'SMTP password')}
297
+ error={fieldErrors.smtpPassword}
298
+ >
299
+ <PasswordInput
300
+ value={form.smtpPassword}
301
+ onChange={(event) => update('smtpPassword', event.target.value)}
302
+ aria-invalid={Boolean(fieldErrors.smtpPassword)}
303
+ />
304
+ </Field>
305
+ </div>
306
+
307
+ <DialogFooter>
308
+ <Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>
309
+ {t('communication_channels.profile.connect.cancel', 'Cancel')}
310
+ </Button>
311
+ <Button type="button" onClick={() => void submit()} disabled={pending}>
312
+ {pending
313
+ ? t('communication_channels.profile.connect.connecting', 'Connecting...')
314
+ : t('communication_channels.profile.connect.save', 'Connect')}
315
+ </Button>
316
+ </DialogFooter>
317
+ </DialogContent>
318
+ </Dialog>
319
+ </>
320
+ )
321
+ }
322
+
323
+ function Field(props: {
324
+ label: string
325
+ error?: string
326
+ children: React.ReactNode
327
+ }) {
328
+ return (
329
+ <label className="grid gap-1.5">
330
+ <Label asChild>
331
+ <span>{props.label}</span>
332
+ </Label>
333
+ {props.children}
334
+ {props.error ? <span className="text-xs text-destructive">{props.error}</span> : null}
335
+ </label>
336
+ )
337
+ }
338
+
339
+ function TlsSelect(props: { value: TlsMode; onChange: (value: TlsMode) => void }) {
340
+ const t = useT()
341
+ return (
342
+ <Select value={props.value} onValueChange={(value) => props.onChange(value as TlsMode)}>
343
+ <SelectTrigger>
344
+ <SelectValue />
345
+ </SelectTrigger>
346
+ <SelectContent>
347
+ <SelectItem value="tls">
348
+ {t('communication_channels.profile.connect.tls.tls', 'TLS')}
349
+ </SelectItem>
350
+ <SelectItem value="starttls">
351
+ {t('communication_channels.profile.connect.tls.starttls', 'STARTTLS')}
352
+ </SelectItem>
353
+ <SelectItem value="none">
354
+ {t('communication_channels.profile.connect.tls.none', 'None')}
355
+ </SelectItem>
356
+ </SelectContent>
357
+ </Select>
358
+ )
359
+ }
@@ -0,0 +1,16 @@
1
+ import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'
2
+ import ConnectImapWidget from './widget.client'
3
+
4
+ const widget: InjectionWidgetModule<Record<string, unknown>, Record<string, unknown>> = {
5
+ metadata: {
6
+ id: 'channel_imap.injection.connect',
7
+ title: 'Connect IMAP',
8
+ description: 'Connects a per-user IMAP and SMTP mailbox.',
9
+ features: ['communication_channels.connect_user_channel'],
10
+ priority: 100,
11
+ enabled: true,
12
+ },
13
+ Widget: ConnectImapWidget,
14
+ }
15
+
16
+ export default widget
@@ -0,0 +1,12 @@
1
+ import type { ModuleInjectionTable } from '@open-mercato/shared/modules/widgets/injection'
2
+
3
+ export const injectionTable: ModuleInjectionTable = {
4
+ 'profile:communication-channels:connect': [
5
+ {
6
+ widgetId: 'channel_imap.injection.connect',
7
+ priority: 100,
8
+ },
9
+ ],
10
+ }
11
+
12
+ export default injectionTable
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "../../tsconfig.base.json",
4
+ "compilerOptions": {
5
+ "noEmit": true
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist", "**/__tests__/**"]
9
+ }
package/watch.mjs ADDED
@@ -0,0 +1,7 @@
1
+ import { watch } from '../../scripts/watch.mjs'
2
+ import { dirname } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+
7
+ watch(__dirname)