@open-mercato/channel-imap 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +56 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.js +62 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-002.spec.js +19 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-002.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-003.spec.js +16 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-003.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-021.spec.js +26 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-021.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-022.spec.js +27 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-022.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-023.spec.js +15 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-023.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-024.spec.js +15 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-024.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-025.spec.js +6 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-025.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-026.spec.js +6 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-026.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-027.spec.js +6 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-027.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-028.spec.js +6 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-028.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-029.spec.js +48 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-029.spec.js.map +7 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-030.spec.js +6 -0
- package/dist/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-030.spec.js.map +7 -0
- package/dist/modules/channel_imap/acl.js +10 -0
- package/dist/modules/channel_imap/acl.js.map +7 -0
- package/dist/modules/channel_imap/di.js +23 -0
- package/dist/modules/channel_imap/di.js.map +7 -0
- package/dist/modules/channel_imap/index.js +9 -0
- package/dist/modules/channel_imap/index.js.map +7 -0
- package/dist/modules/channel_imap/integration.js +135 -0
- package/dist/modules/channel_imap/integration.js.map +7 -0
- package/dist/modules/channel_imap/lib/adapter.js +291 -0
- package/dist/modules/channel_imap/lib/adapter.js.map +7 -0
- package/dist/modules/channel_imap/lib/capabilities.js +8 -0
- package/dist/modules/channel_imap/lib/capabilities.js.map +7 -0
- package/dist/modules/channel_imap/lib/convert-outbound.js +54 -0
- package/dist/modules/channel_imap/lib/convert-outbound.js.map +7 -0
- package/dist/modules/channel_imap/lib/credentials.js +104 -0
- package/dist/modules/channel_imap/lib/credentials.js.map +7 -0
- package/dist/modules/channel_imap/lib/health.js +39 -0
- package/dist/modules/channel_imap/lib/health.js.map +7 -0
- package/dist/modules/channel_imap/lib/host-pinning.js +34 -0
- package/dist/modules/channel_imap/lib/host-pinning.js.map +7 -0
- package/dist/modules/channel_imap/lib/imap-client.js +210 -0
- package/dist/modules/channel_imap/lib/imap-client.js.map +7 -0
- package/dist/modules/channel_imap/lib/normalize-inbound.js +19 -0
- package/dist/modules/channel_imap/lib/normalize-inbound.js.map +7 -0
- package/dist/modules/channel_imap/lib/smtp-client.js +113 -0
- package/dist/modules/channel_imap/lib/smtp-client.js.map +7 -0
- package/dist/modules/channel_imap/lib/transport.js +17 -0
- package/dist/modules/channel_imap/lib/transport.js.map +7 -0
- package/dist/modules/channel_imap/lib/validate-credentials.js +69 -0
- package/dist/modules/channel_imap/lib/validate-credentials.js.map +7 -0
- package/dist/modules/channel_imap/setup.js +25 -0
- package/dist/modules/channel_imap/setup.js.map +7 -0
- package/dist/modules/channel_imap/widgets/injection/connect/widget.client.js +337 -0
- package/dist/modules/channel_imap/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_imap/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_imap/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_imap/widgets/injection-table.js +14 -0
- package/dist/modules/channel_imap/widgets/injection-table.js.map +7 -0
- package/jest.config.cjs +34 -0
- package/package.json +99 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-001.spec.ts +80 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-002.spec.ts +28 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-003.spec.ts +23 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-021.spec.ts +40 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-022.spec.ts +38 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-023.spec.ts +31 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-024.spec.ts +27 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-025.spec.ts +23 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-026.spec.ts +18 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-027.spec.ts +18 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-028.spec.ts +19 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-029.spec.ts +72 -0
- package/src/modules/channel_imap/__integration__/TC-CHANNEL-EMAIL-030.spec.ts +19 -0
- package/src/modules/channel_imap/acl.ts +6 -0
- package/src/modules/channel_imap/di.ts +26 -0
- package/src/modules/channel_imap/index.ts +6 -0
- package/src/modules/channel_imap/integration.ts +131 -0
- package/src/modules/channel_imap/lib/__tests__/adapter.test.ts +499 -0
- package/src/modules/channel_imap/lib/__tests__/convert-outbound.test.ts +73 -0
- package/src/modules/channel_imap/lib/__tests__/credentials.test.ts +154 -0
- package/src/modules/channel_imap/lib/__tests__/host-pinning.test.ts +68 -0
- package/src/modules/channel_imap/lib/__tests__/imap-client.test.ts +180 -0
- package/src/modules/channel_imap/lib/__tests__/normalize-inbound.test.ts +126 -0
- package/src/modules/channel_imap/lib/__tests__/transport.test.ts +68 -0
- package/src/modules/channel_imap/lib/__tests__/validate-credentials.test.ts +156 -0
- package/src/modules/channel_imap/lib/adapter.ts +451 -0
- package/src/modules/channel_imap/lib/capabilities.ts +16 -0
- package/src/modules/channel_imap/lib/convert-outbound.ts +79 -0
- package/src/modules/channel_imap/lib/credentials.ts +172 -0
- package/src/modules/channel_imap/lib/health.ts +70 -0
- package/src/modules/channel_imap/lib/host-pinning.ts +59 -0
- package/src/modules/channel_imap/lib/imap-client.ts +382 -0
- package/src/modules/channel_imap/lib/normalize-inbound.ts +47 -0
- package/src/modules/channel_imap/lib/smtp-client.ts +214 -0
- package/src/modules/channel_imap/lib/transport.ts +37 -0
- package/src/modules/channel_imap/lib/validate-credentials.ts +98 -0
- package/src/modules/channel_imap/setup.ts +34 -0
- package/src/modules/channel_imap/widgets/injection/connect/widget.client.tsx +359 -0
- package/src/modules/channel_imap/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_imap/widgets/injection-table.ts +12 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +7 -0
|
@@ -0,0 +1,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