@open-mercato/core 0.8.1-develop.7221.1.ece7ca874e → 0.8.1-develop.7227.1.9248c14599
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 +1 -1
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js +166 -0
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js +16 -0
- package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.js +68 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js +25 -0
- package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/page.js +11 -0
- package/dist/modules/catalog/backend/catalog/prices/page.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/prices/page.meta.js +16 -0
- package/dist/modules/catalog/backend/catalog/prices/page.meta.js.map +7 -0
- package/dist/modules/catalog/components/prices/PriceScopeSelectors.js +201 -0
- package/dist/modules/catalog/components/prices/PriceScopeSelectors.js.map +7 -0
- package/dist/modules/catalog/components/prices/PricesDataTable.js +273 -0
- package/dist/modules/catalog/components/prices/PricesDataTable.js.map +7 -0
- package/dist/modules/catalog/components/prices/normalizePriceRecord.js +43 -0
- package/dist/modules/catalog/components/prices/normalizePriceRecord.js.map +7 -0
- package/dist/modules/catalog/components/prices/priceFormFields.js +195 -0
- package/dist/modules/catalog/components/prices/priceFormFields.js.map +7 -0
- package/dist/modules/catalog/components/prices/useBatchLabels.js +42 -0
- package/dist/modules/catalog/components/prices/useBatchLabels.js.map +7 -0
- package/dist/modules/catalog/data/validators.js +21 -2
- package/dist/modules/catalog/data/validators.js.map +2 -2
- package/dist/modules/catalog/extension-points.js +2 -1
- package/dist/modules/catalog/extension-points.js.map +2 -2
- package/dist/modules/catalog/lib/pricing.js +37 -8
- package/dist/modules/catalog/lib/pricing.js.map +2 -2
- package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js +5 -1
- package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js.map +2 -2
- package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js +88 -0
- package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js.map +7 -0
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +17 -19
- package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
- package/dist/modules/communication_channels/commands/connect-credential-channel.js +7 -2
- package/dist/modules/communication_channels/commands/connect-credential-channel.js.map +2 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js +11 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
- package/dist/modules/communication_channels/commands/queue-import-history.js +40 -7
- package/dist/modules/communication_channels/commands/queue-import-history.js.map +2 -2
- package/dist/modules/communication_channels/extension-points.js +1 -1
- package/dist/modules/communication_channels/extension-points.js.map +1 -1
- package/dist/modules/communication_channels/lib/connect-channel.js +33 -0
- package/dist/modules/communication_channels/lib/connect-channel.js.map +2 -2
- package/dist/modules/communication_channels/lib/import-history-limits.js +69 -0
- package/dist/modules/communication_channels/lib/import-history-limits.js.map +7 -0
- package/dist/modules/communication_channels/workers/channel-import-history.js +36 -2
- package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
- package/dist/modules/messages/api/route.js +5 -1
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/commands/messages.js +3 -2
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/data/validators.js +12 -2
- package/dist/modules/messages/data/validators.js.map +2 -2
- package/dist/modules/messages/subscribers/message-notification.js +4 -1
- package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +19 -1
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/catalog/AGENTS.md +6 -2
- package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.ts +12 -0
- package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.tsx +198 -0
- package/src/modules/catalog/backend/catalog/prices/create/page.meta.ts +23 -0
- package/src/modules/catalog/backend/catalog/prices/create/page.tsx +70 -0
- package/src/modules/catalog/backend/catalog/prices/page.meta.ts +12 -0
- package/src/modules/catalog/backend/catalog/prices/page.tsx +14 -0
- package/src/modules/catalog/components/prices/PriceScopeSelectors.tsx +243 -0
- package/src/modules/catalog/components/prices/PricesDataTable.tsx +320 -0
- package/src/modules/catalog/components/prices/normalizePriceRecord.ts +71 -0
- package/src/modules/catalog/components/prices/priceFormFields.tsx +228 -0
- package/src/modules/catalog/components/prices/useBatchLabels.ts +57 -0
- package/src/modules/catalog/data/validators.ts +43 -2
- package/src/modules/catalog/extension-points.ts +1 -0
- package/src/modules/catalog/i18n/de.json +72 -0
- package/src/modules/catalog/i18n/en.json +72 -0
- package/src/modules/catalog/i18n/es.json +72 -0
- package/src/modules/catalog/i18n/ko.json +72 -0
- package/src/modules/catalog/i18n/pl.json +72 -0
- package/src/modules/catalog/lib/pricing.ts +103 -9
- package/src/modules/communication_channels/api/post/channels/[id]/import-history/route.ts +6 -1
- package/src/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.tsx +120 -0
- package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +20 -17
- package/src/modules/communication_channels/commands/connect-credential-channel.ts +9 -0
- package/src/modules/communication_channels/commands/ingest-inbound-message.ts +17 -2
- package/src/modules/communication_channels/commands/queue-import-history.ts +59 -11
- package/src/modules/communication_channels/extension-points.ts +1 -1
- package/src/modules/communication_channels/i18n/de.json +4 -3
- package/src/modules/communication_channels/i18n/en.json +4 -3
- package/src/modules/communication_channels/i18n/es.json +4 -3
- package/src/modules/communication_channels/i18n/ko.json +4 -3
- package/src/modules/communication_channels/i18n/pl.json +4 -3
- package/src/modules/communication_channels/lib/adapter.ts +16 -2
- package/src/modules/communication_channels/lib/connect-channel.ts +49 -0
- package/src/modules/communication_channels/lib/import-history-limits.ts +109 -0
- package/src/modules/communication_channels/workers/channel-import-history.ts +46 -5
- package/src/modules/messages/api/route.ts +8 -2
- package/src/modules/messages/commands/messages.ts +17 -2
- package/src/modules/messages/data/validators.ts +26 -11
- package/src/modules/messages/subscribers/message-notification.ts +17 -5
- package/src/modules/query_index/lib/engine.ts +23 -1
- package/src/modules/sales/AGENTS.md +2 -3
|
@@ -8,6 +8,7 @@ import type { CrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
|
8
8
|
import { CommunicationChannel } from '../../../../../data/entities'
|
|
9
9
|
import { ChannelAccessDeniedError, assertCanManageChannel } from '../../../../../lib/access-control'
|
|
10
10
|
import {
|
|
11
|
+
getQueueImportHistorySchema,
|
|
11
12
|
queueImportHistory,
|
|
12
13
|
queueImportHistorySchema,
|
|
13
14
|
} from '../../../../../commands/queue-import-history'
|
|
@@ -50,6 +51,10 @@ type RouteContext = {
|
|
|
50
51
|
|
|
51
52
|
const bodySchema = queueImportHistorySchema.omit({ channelId: true })
|
|
52
53
|
|
|
54
|
+
function buildBodySchema() {
|
|
55
|
+
return getQueueImportHistorySchema().omit({ channelId: true })
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
export async function POST(req: Request, context: RouteContext): Promise<Response> {
|
|
54
59
|
const { id } = await context.params
|
|
55
60
|
if (!z.string().uuid().safeParse(id).success) {
|
|
@@ -70,7 +75,7 @@ export async function POST(req: Request, context: RouteContext): Promise<Respons
|
|
|
70
75
|
} catch {
|
|
71
76
|
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
72
77
|
}
|
|
73
|
-
const parsed =
|
|
78
|
+
const parsed = buildBodySchema().safeParse(rawBody)
|
|
74
79
|
if (!parsed.success) {
|
|
75
80
|
const first = parsed.error.issues[0]
|
|
76
81
|
return NextResponse.json(
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { ChevronDown } from 'lucide-react'
|
|
5
|
+
import { extensionPoints } from '@open-mercato/core/modules/communication_channels/extension-points'
|
|
6
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
7
|
+
import { InjectionSpot, useInjectionWidgets } from '@open-mercato/ui/backend/injection/InjectionSpot'
|
|
8
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
9
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
10
|
+
|
|
11
|
+
const CONNECT_MENU_TRIGGER_ID = 'communication-channels-connect-trigger'
|
|
12
|
+
const CONNECT_MENU_PANEL_ID = 'communication-channels-connect-menu'
|
|
13
|
+
|
|
14
|
+
type ConnectChannelMenuProps = {
|
|
15
|
+
onConnected: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Single "Connect channel" entry point for the profile page.
|
|
20
|
+
*
|
|
21
|
+
* Every `channel-*` package injects its own connect control into the
|
|
22
|
+
* `profile:communication-channels:connect` spot. Rendering that spot inline put
|
|
23
|
+
* each provider button side by side in the page header, unspaced and growing
|
|
24
|
+
* wider with every installed provider (#5595). The widgets now stack inside a
|
|
25
|
+
* dropdown, so a new provider lengthens a list instead of widening a row.
|
|
26
|
+
*
|
|
27
|
+
* The panel stays mounted and is hidden with CSS rather than being conditionally
|
|
28
|
+
* rendered: `channel-imap` and `channel-discord` own their connect `Dialog`
|
|
29
|
+
* inside the injected widget, so unmounting the panel on close would tear down
|
|
30
|
+
* an open dialog.
|
|
31
|
+
*/
|
|
32
|
+
export function ConnectChannelMenu({ onConnected }: ConnectChannelMenuProps): React.JSX.Element | null {
|
|
33
|
+
const t = useT()
|
|
34
|
+
const spotId = extensionPoints.hosts.profileConnect.spotId
|
|
35
|
+
const [open, setOpen] = React.useState(false)
|
|
36
|
+
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
37
|
+
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
|
38
|
+
|
|
39
|
+
const context = React.useMemo(() => ({ reload: onConnected }), [onConnected])
|
|
40
|
+
const { widgets } = useInjectionWidgets(spotId, { context, triggerOnLoad: true })
|
|
41
|
+
|
|
42
|
+
React.useEffect(() => {
|
|
43
|
+
if (!open) return
|
|
44
|
+
// A provider's credential dialog renders through a portal at `document.body`,
|
|
45
|
+
// so it sits outside `containerRef` and a click inside it reads as an outside
|
|
46
|
+
// click. Dismissing on those would collapse the panel behind the open dialog —
|
|
47
|
+
// the tear-down the panel is kept mounted to avoid in the first place.
|
|
48
|
+
//
|
|
49
|
+
// Escape is deliberately not guarded the same way. The provider widgets render
|
|
50
|
+
// a controlled `Dialog` with no `DialogTrigger`, so Radix has no recorded
|
|
51
|
+
// element to hand focus back to and drops it on `<body>` when the dialog
|
|
52
|
+
// closes — measured in Chromium, where focus was still on `<body>` two seconds
|
|
53
|
+
// later. Closing the menu here and calling `focus()` is then the only thing
|
|
54
|
+
// that leaves a keyboard user anywhere usable, so Escape dismisses both layers
|
|
55
|
+
// together on purpose until the provider widgets restore focus themselves.
|
|
56
|
+
function isInsideDialog(target: EventTarget | null): boolean {
|
|
57
|
+
const element = target as Element | null
|
|
58
|
+
return typeof element?.closest === 'function' && element.closest('[role="dialog"]') !== null
|
|
59
|
+
}
|
|
60
|
+
function onDocumentMouseDown(event: MouseEvent) {
|
|
61
|
+
if (isInsideDialog(event.target)) return
|
|
62
|
+
const target = event.target as Node
|
|
63
|
+
if (containerRef.current && !containerRef.current.contains(target)) setOpen(false)
|
|
64
|
+
}
|
|
65
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
66
|
+
if (event.key !== 'Escape') return
|
|
67
|
+
setOpen(false)
|
|
68
|
+
triggerRef.current?.focus()
|
|
69
|
+
}
|
|
70
|
+
document.addEventListener('mousedown', onDocumentMouseDown)
|
|
71
|
+
document.addEventListener('keydown', onKeyDown)
|
|
72
|
+
return () => {
|
|
73
|
+
document.removeEventListener('mousedown', onDocumentMouseDown)
|
|
74
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
75
|
+
}
|
|
76
|
+
}, [open])
|
|
77
|
+
|
|
78
|
+
if (widgets.length === 0) return null
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div ref={containerRef} className="relative inline-block text-left">
|
|
82
|
+
<Button
|
|
83
|
+
ref={triggerRef}
|
|
84
|
+
id={CONNECT_MENU_TRIGGER_ID}
|
|
85
|
+
type="button"
|
|
86
|
+
variant="outline"
|
|
87
|
+
aria-expanded={open}
|
|
88
|
+
aria-controls={CONNECT_MENU_PANEL_ID}
|
|
89
|
+
onClick={() => setOpen((current) => !current)}
|
|
90
|
+
>
|
|
91
|
+
{t('communication_channels.profile.connect.menu', 'Connect channel')}
|
|
92
|
+
<ChevronDown className={cn('size-4 transition-transform', open && 'rotate-180')} />
|
|
93
|
+
</Button>
|
|
94
|
+
{/* A disclosure group, not `role="menu"`: the rows are provider-owned
|
|
95
|
+
widgets rendering plain buttons, and ARIA only allows `menuitem`-shaped
|
|
96
|
+
children under a menu — declaring one would hide them from assistive
|
|
97
|
+
technology instead of describing them. */}
|
|
98
|
+
<div
|
|
99
|
+
id={CONNECT_MENU_PANEL_ID}
|
|
100
|
+
role="group"
|
|
101
|
+
aria-labelledby={CONNECT_MENU_TRIGGER_ID}
|
|
102
|
+
data-testid="connect-channel-menu-panel"
|
|
103
|
+
className={cn(
|
|
104
|
+
// Anchoring flips with the header: the header wraps on a phone, which
|
|
105
|
+
// moves the trigger to the left edge, and right-anchoring a panel
|
|
106
|
+
// wider than the trigger then pushes it off-screen. Left-anchor below
|
|
107
|
+
// `sm`, right-anchor from `sm` up where the trigger sits right. The
|
|
108
|
+
// `max-w-` clamp (mirroring RowActions) keeps a long provider label
|
|
109
|
+
// from widening the panel past the viewport either way.
|
|
110
|
+
'absolute left-0 top-full z-dropdown mt-2 w-max min-w-56 max-w-[calc(100vw-1rem)] flex-col items-stretch gap-2 rounded-md border bg-background p-2 shadow-md sm:left-auto sm:right-0',
|
|
111
|
+
open ? 'flex' : 'hidden',
|
|
112
|
+
)}
|
|
113
|
+
>
|
|
114
|
+
<InjectionSpot spotId={spotId} context={context} data={{}} widgetsOverride={widgets} />
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default ConnectChannelMenu
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import { extensionPoints } from '@open-mercato/core/modules/communication_channels/extension-points'
|
|
5
|
+
import { getImportHistoryLimits } from '@open-mercato/core/modules/communication_channels/lib/import-history-limits'
|
|
5
6
|
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
6
7
|
import { useRouter, useSearchParams } from 'next/navigation'
|
|
7
8
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
@@ -21,11 +22,11 @@ import { Input } from '@open-mercato/ui/primitives/input'
|
|
|
21
22
|
import { Label } from '@open-mercato/ui/primitives/label'
|
|
22
23
|
import { Textarea } from '@open-mercato/ui/primitives/textarea'
|
|
23
24
|
import { KbdShortcut } from '@open-mercato/ui/primitives/kbd'
|
|
24
|
-
import { InjectionSpot } from '@open-mercato/ui/backend/injection/InjectionSpot'
|
|
25
25
|
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
26
26
|
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
27
27
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
28
28
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
29
|
+
import { ConnectChannelMenu } from './ConnectChannelMenu'
|
|
29
30
|
|
|
30
31
|
type ChannelRow = {
|
|
31
32
|
id: string
|
|
@@ -146,6 +147,8 @@ export default function ProfileCommunicationChannelsPage() {
|
|
|
146
147
|
|
|
147
148
|
const reauthRows = rows.filter((r) => r.status === 'requires_reauth')
|
|
148
149
|
|
|
150
|
+
const reloadChannels = React.useCallback(() => setReloadKey((k) => k + 1), [])
|
|
151
|
+
|
|
149
152
|
const onSetPrimary = React.useCallback(
|
|
150
153
|
async (channelId: string) => {
|
|
151
154
|
let response
|
|
@@ -500,8 +503,8 @@ export default function ProfileCommunicationChannelsPage() {
|
|
|
500
503
|
return (
|
|
501
504
|
<Page>
|
|
502
505
|
<PageBody>
|
|
503
|
-
<header className="mb-4 flex items-
|
|
504
|
-
<div>
|
|
506
|
+
<header className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
|
507
|
+
<div className="min-w-0">
|
|
505
508
|
<h1 className="text-2xl font-semibold">
|
|
506
509
|
{t('communication_channels.profile.title', 'My communication channels')}
|
|
507
510
|
</h1>
|
|
@@ -513,12 +516,9 @@ export default function ProfileCommunicationChannelsPage() {
|
|
|
513
516
|
</p>
|
|
514
517
|
</div>
|
|
515
518
|
{/* Provider connect entry points injected by each channel-* package
|
|
516
|
-
(channel-gmail, channel-imap) via UMES.
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
context={{ reload: () => setReloadKey((k) => k + 1) }}
|
|
520
|
-
data={{}}
|
|
521
|
-
/>
|
|
519
|
+
(channel-gmail, channel-imap) via UMES. They stack inside one
|
|
520
|
+
dropdown so the header does not widen per installed provider. */}
|
|
521
|
+
<ConnectChannelMenu onConnected={reloadChannels} />
|
|
522
522
|
</header>
|
|
523
523
|
|
|
524
524
|
{reauthRows.length > 0 ? (
|
|
@@ -543,7 +543,7 @@ export default function ProfileCommunicationChannelsPage() {
|
|
|
543
543
|
error={errorMessage}
|
|
544
544
|
emptyState={t(
|
|
545
545
|
'communication_channels.profile.empty',
|
|
546
|
-
'You have no connected channels yet.
|
|
546
|
+
'You have no connected channels yet. Add one using the menu at the top of this page.',
|
|
547
547
|
)}
|
|
548
548
|
/>
|
|
549
549
|
<ImportHistoryDialog
|
|
@@ -575,6 +575,7 @@ type ImportHistoryDialogProps = {
|
|
|
575
575
|
|
|
576
576
|
function ImportHistoryDialog({ channel, onClose, onQueued }: ImportHistoryDialogProps): React.JSX.Element {
|
|
577
577
|
const t = useT()
|
|
578
|
+
const importLimits = React.useMemo(() => getImportHistoryLimits(channel?.providerKey), [channel?.providerKey])
|
|
578
579
|
const [sinceDays, setSinceDays] = React.useState('30')
|
|
579
580
|
const [contactEmails, setContactEmails] = React.useState('')
|
|
580
581
|
const [maxMessages, setMaxMessages] = React.useState('500')
|
|
@@ -600,16 +601,18 @@ function ImportHistoryDialog({ channel, onClose, onQueued }: ImportHistoryDialog
|
|
|
600
601
|
const sinceNum = Number.parseInt(sinceDays, 10)
|
|
601
602
|
const maxNum = Number.parseInt(maxMessages, 10)
|
|
602
603
|
const errors: Record<string, string> = {}
|
|
603
|
-
if (!Number.isFinite(sinceNum) || sinceNum < 1 || sinceNum >
|
|
604
|
+
if (!Number.isFinite(sinceNum) || sinceNum < 1 || sinceNum > importLimits.maxSinceDays) {
|
|
604
605
|
errors.sinceDays = t(
|
|
605
606
|
'communication_channels.profile.importHistory.errors.sinceDays',
|
|
606
|
-
'Choose a number between 1 and
|
|
607
|
+
'Choose a number between 1 and {max} days.',
|
|
608
|
+
{ max: importLimits.maxSinceDays },
|
|
607
609
|
)
|
|
608
610
|
}
|
|
609
|
-
if (!Number.isFinite(maxNum) || maxNum < 1 || maxNum >
|
|
611
|
+
if (!Number.isFinite(maxNum) || maxNum < 1 || maxNum > importLimits.maxMessages) {
|
|
610
612
|
errors.maxMessages = t(
|
|
611
613
|
'communication_channels.profile.importHistory.errors.maxMessages',
|
|
612
|
-
'Choose a number between 1 and
|
|
614
|
+
'Choose a number between 1 and {max} messages.',
|
|
615
|
+
{ max: importLimits.maxMessages },
|
|
613
616
|
)
|
|
614
617
|
}
|
|
615
618
|
const parsedEmails = contactEmails
|
|
@@ -687,7 +690,7 @@ function ImportHistoryDialog({ channel, onClose, onQueued }: ImportHistoryDialog
|
|
|
687
690
|
'success',
|
|
688
691
|
)
|
|
689
692
|
onQueued()
|
|
690
|
-
}, [channel, sinceDays, maxMessages, contactEmails, submitting, t, onQueued, retryLastMutation, runMutation])
|
|
693
|
+
}, [channel, sinceDays, maxMessages, contactEmails, submitting, t, onQueued, retryLastMutation, runMutation, importLimits])
|
|
691
694
|
|
|
692
695
|
const handleKeyDown = React.useCallback(
|
|
693
696
|
(event: React.KeyboardEvent) => {
|
|
@@ -723,7 +726,7 @@ function ImportHistoryDialog({ channel, onClose, onQueued }: ImportHistoryDialog
|
|
|
723
726
|
id="import-history-since"
|
|
724
727
|
type="number"
|
|
725
728
|
min={1}
|
|
726
|
-
max={
|
|
729
|
+
max={importLimits.maxSinceDays}
|
|
727
730
|
value={sinceDays}
|
|
728
731
|
onChange={(e) => setSinceDays(e.target.value)}
|
|
729
732
|
aria-invalid={Boolean(fieldErrors.sinceDays)}
|
|
@@ -771,7 +774,7 @@ function ImportHistoryDialog({ channel, onClose, onQueued }: ImportHistoryDialog
|
|
|
771
774
|
id="import-history-max"
|
|
772
775
|
type="number"
|
|
773
776
|
min={1}
|
|
774
|
-
max={
|
|
777
|
+
max={importLimits.maxMessages}
|
|
775
778
|
value={maxMessages}
|
|
776
779
|
onChange={(e) => setMaxMessages(e.target.value)}
|
|
777
780
|
aria-invalid={Boolean(fieldErrors.maxMessages)}
|
|
@@ -104,6 +104,7 @@ const connectCredentialChannelCommand: CommandHandler<
|
|
|
104
104
|
const effectiveUserId = tenantScoped ? null : input.userId
|
|
105
105
|
|
|
106
106
|
// Optional credential validation.
|
|
107
|
+
let adapterIdentifier: string | null = null
|
|
107
108
|
if (typeof adapter.validateCredentials === 'function') {
|
|
108
109
|
const validation = await adapter.validateCredentials({
|
|
109
110
|
providerKey: input.providerKey,
|
|
@@ -120,6 +121,9 @@ const connectCredentialChannelCommand: CommandHandler<
|
|
|
120
121
|
errorCodes: validation.errorCodes,
|
|
121
122
|
}
|
|
122
123
|
}
|
|
124
|
+
if (typeof validation.externalIdentifier === 'string' && validation.externalIdentifier.length > 0) {
|
|
125
|
+
adapterIdentifier = validation.externalIdentifier
|
|
126
|
+
}
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
// Persist credentials best-effort.
|
|
@@ -200,8 +204,12 @@ const connectCredentialChannelCommand: CommandHandler<
|
|
|
200
204
|
// carry it as `fromAddress`; other credential providers may use `username` or
|
|
201
205
|
// `email`. Normalize emails to lowercase so the cross-provider duplicate guard
|
|
202
206
|
// and the per-(tenant,user,provider,mailbox) heal index match canonically.
|
|
207
|
+
// An identity the adapter reported during validation wins: providers such as
|
|
208
|
+
// Discord have no email-shaped key, and without it every reconnect inserted a
|
|
209
|
+
// duplicate row instead of healing the existing one (#4977).
|
|
203
210
|
const credBag = input.credentials as Record<string, unknown>
|
|
204
211
|
const rawIdentifier =
|
|
212
|
+
adapterIdentifier ??
|
|
205
213
|
(typeof credBag.username === 'string' && credBag.username.length > 0 ? credBag.username : null) ??
|
|
206
214
|
(typeof credBag.email === 'string' && credBag.email.length > 0 ? credBag.email : null) ??
|
|
207
215
|
(typeof credBag.fromAddress === 'string' && credBag.fromAddress.length > 0
|
|
@@ -233,6 +241,7 @@ const connectCredentialChannelCommand: CommandHandler<
|
|
|
233
241
|
organizationId: tenantScoped ? null : input.scope.organizationId ?? null,
|
|
234
242
|
},
|
|
235
243
|
pollIntervalSeconds: input.pollIntervalSeconds,
|
|
244
|
+
adoptUnidentifiedChannel: adapterIdentifier !== null,
|
|
236
245
|
})
|
|
237
246
|
} catch (err) {
|
|
238
247
|
// Same mailbox already connected via another provider — reject so we don't
|
|
@@ -213,6 +213,7 @@ const ingestInboundMessageCommand: CommandHandler<IngestInboundMessageInput, Ing
|
|
|
213
213
|
tenantId: input.scope.tenantId,
|
|
214
214
|
organizationId: input.scope.organizationId ?? null,
|
|
215
215
|
lastMessageAt: m.timestamp ?? new Date(),
|
|
216
|
+
assignedUserId: channel.userId ?? null,
|
|
216
217
|
})
|
|
217
218
|
em.persist(conversation)
|
|
218
219
|
conversationCreated = true
|
|
@@ -378,6 +379,13 @@ const ingestInboundMessageCommand: CommandHandler<IngestInboundMessageInput, Ing
|
|
|
378
379
|
: rawBody
|
|
379
380
|
const safeSubject = (m.subject ?? '').trim() || '(no subject)'
|
|
380
381
|
|
|
382
|
+
// #6106: a per-user channel is its owner's inbox, so the owner is the
|
|
383
|
+
// default assignee. Without this fallback the first message of a thread
|
|
384
|
+
// (no mapping yet) and every message on an unassigned mapping had no
|
|
385
|
+
// recipient, and the participant-scoped inbox showed it to nobody. A
|
|
386
|
+
// manual assignment stays authoritative; tenant-wide channels have no owner.
|
|
387
|
+
const routedAssigneeId = mapping?.assignedUserId ?? channel.userId ?? null
|
|
388
|
+
|
|
381
389
|
const composeInput = {
|
|
382
390
|
type: `channel.${input.providerKey}`,
|
|
383
391
|
visibility: 'public' as const,
|
|
@@ -390,8 +398,14 @@ const ingestInboundMessageCommand: CommandHandler<IngestInboundMessageInput, Ing
|
|
|
390
398
|
// senders have no address (Discord, Slack, SMS…) are validated without it.
|
|
391
399
|
// The messages validator fails closed on any type it does not recognize.
|
|
392
400
|
sourceChannelType: input.channelType,
|
|
393
|
-
|
|
394
|
-
|
|
401
|
+
// #6093: this message came in from the channel, so the recipient below
|
|
402
|
+
// is internal routing (the conversation's assignee), not an external
|
|
403
|
+
// addressee. Without the flag the hub's "no recipients on a public
|
|
404
|
+
// message" rule rejected every message in an assigned conversation and
|
|
405
|
+
// the worker dropped it as a permanent failure.
|
|
406
|
+
inboundFromChannel: true,
|
|
407
|
+
recipients: routedAssigneeId
|
|
408
|
+
? [{ userId: routedAssigneeId, type: 'to' as const }]
|
|
395
409
|
: [],
|
|
396
410
|
subject: safeSubject,
|
|
397
411
|
body: truncatedBody,
|
|
@@ -440,6 +454,7 @@ const ingestInboundMessageCommand: CommandHandler<IngestInboundMessageInput, Ing
|
|
|
440
454
|
channelId: input.channelId,
|
|
441
455
|
providerKey: input.providerKey,
|
|
442
456
|
externalThreadRef: m.externalConversationId,
|
|
457
|
+
assignedUserId: channel.userId ?? null,
|
|
443
458
|
tenantId: input.scope.tenantId,
|
|
444
459
|
organizationId: input.scope.organizationId ?? null,
|
|
445
460
|
})
|
|
@@ -10,6 +10,11 @@ import type {
|
|
|
10
10
|
import { CommunicationChannel } from '../data/entities'
|
|
11
11
|
import { getChannelAdapterRegistry } from '../lib/adapter-registry-singleton'
|
|
12
12
|
import { COMMUNICATION_CHANNELS_QUEUES, getCommunicationChannelsQueue } from '../lib/queue'
|
|
13
|
+
import {
|
|
14
|
+
IMPORT_HISTORY_MAX_CONTACT_EMAILS,
|
|
15
|
+
getImportHistoryLimits,
|
|
16
|
+
type ImportHistoryLimits,
|
|
17
|
+
} from '../lib/import-history-limits'
|
|
13
18
|
|
|
14
19
|
/**
|
|
15
20
|
* Spec B § Phase B6 — operator-triggered backlog import.
|
|
@@ -24,17 +29,40 @@ import { COMMUNICATION_CHANNELS_QUEUES, getCommunicationChannelsQueue } from '..
|
|
|
24
29
|
* dropped guard is still safe.
|
|
25
30
|
*/
|
|
26
31
|
|
|
27
|
-
export
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
export function createQueueImportHistorySchema(limits: ImportHistoryLimits = getImportHistoryLimits()) {
|
|
33
|
+
return z.object({
|
|
34
|
+
channelId: z.string().uuid(),
|
|
35
|
+
sinceDays: z.number().int().min(1).max(limits.maxSinceDays).default(limits.defaultSinceDays),
|
|
36
|
+
contactEmails: z
|
|
37
|
+
.array(z.string().email().max(255))
|
|
38
|
+
.max(IMPORT_HISTORY_MAX_CONTACT_EMAILS)
|
|
39
|
+
.optional(),
|
|
40
|
+
maxMessages: z.number().int().min(1).max(limits.maxMessages).default(limits.defaultMaxMessages),
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type QueueImportHistorySchema = ReturnType<typeof createQueueImportHistorySchema>
|
|
45
|
+
|
|
46
|
+
let cachedSchema: { key: string; schema: QueueImportHistorySchema } | null = null
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The ceilings come from env, which a test may change after this module was
|
|
50
|
+
* imported, so the schema is rebuilt whenever the effective limits differ from
|
|
51
|
+
* the ones the cached instance was built with.
|
|
52
|
+
*/
|
|
53
|
+
export function getQueueImportHistorySchema(): QueueImportHistorySchema {
|
|
54
|
+
const limits = getImportHistoryLimits()
|
|
55
|
+
const key = `${limits.maxSinceDays}:${limits.maxMessages}`
|
|
56
|
+
if (!cachedSchema || cachedSchema.key !== key) {
|
|
57
|
+
cachedSchema = { key, schema: createQueueImportHistorySchema(limits) }
|
|
58
|
+
}
|
|
59
|
+
return cachedSchema.schema
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Retained for callers that need a statically typed schema instance. */
|
|
63
|
+
export const queueImportHistorySchema = getQueueImportHistorySchema()
|
|
36
64
|
|
|
37
|
-
export type QueueImportHistoryInput = z.infer<
|
|
65
|
+
export type QueueImportHistoryInput = z.infer<QueueImportHistorySchema>
|
|
38
66
|
|
|
39
67
|
export interface QueueImportHistoryScope {
|
|
40
68
|
tenantId: string
|
|
@@ -63,7 +91,7 @@ export async function queueImportHistory(params: {
|
|
|
63
91
|
scope: QueueImportHistoryScope
|
|
64
92
|
input: QueueImportHistoryInput
|
|
65
93
|
}): Promise<QueueImportHistoryResult> {
|
|
66
|
-
const input =
|
|
94
|
+
const input = getQueueImportHistorySchema().parse(params.input)
|
|
67
95
|
const { container, scope } = params
|
|
68
96
|
|
|
69
97
|
const em = (container.resolve('em') as EntityManager).fork()
|
|
@@ -117,6 +145,26 @@ export async function queueImportHistory(params: {
|
|
|
117
145
|
)
|
|
118
146
|
}
|
|
119
147
|
|
|
148
|
+
// The schema above validated against the deployment-wide ceiling; some
|
|
149
|
+
// providers (IMAP) have a lower ceiling of their own (see
|
|
150
|
+
// `import-history-limits.ts`). Reject rather than silently truncate — the
|
|
151
|
+
// adapter itself has no way to report back "I clamped your request".
|
|
152
|
+
const providerLimits = getImportHistoryLimits(channel.providerKey)
|
|
153
|
+
if (input.sinceDays > providerLimits.maxSinceDays) {
|
|
154
|
+
throw createCrudFormError(
|
|
155
|
+
'sinceDays exceeds the limit for this provider',
|
|
156
|
+
{ sinceDays: `Provider "${channel.providerKey}" supports at most ${providerLimits.maxSinceDays} days` },
|
|
157
|
+
{ status: 400 },
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
if (input.maxMessages > providerLimits.maxMessages) {
|
|
161
|
+
throw createCrudFormError(
|
|
162
|
+
'maxMessages exceeds the limit for this provider',
|
|
163
|
+
{ maxMessages: `Provider "${channel.providerKey}" supports at most ${providerLimits.maxMessages} messages` },
|
|
164
|
+
{ status: 400 },
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
120
168
|
const progressService = container.resolve('progressService') as ProgressService
|
|
121
169
|
const progressContext: ProgressServiceContext = {
|
|
122
170
|
tenantId: scope.tenantId,
|
|
@@ -8,7 +8,7 @@ export const extensionPoints = defineModuleExtensionPoints({
|
|
|
8
8
|
family: 'generic',
|
|
9
9
|
spotId: 'profile:communication-channels:connect',
|
|
10
10
|
supported: ['render-widget'],
|
|
11
|
-
source: 'backend/profile/communication-channels/
|
|
11
|
+
source: 'backend/profile/communication-channels/ConnectChannelMenu.tsx',
|
|
12
12
|
}),
|
|
13
13
|
profileChannelsTable: dataTableExtensionHost({ tableId: 'communication_channels.profile.channels', source: 'backend/profile/communication-channels/page.tsx' }),
|
|
14
14
|
},
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
"communication_channels.profile.connect.imap": "IMAP verbinden",
|
|
90
90
|
"communication_channels.profile.connect.imapTitle": "IMAP-Postfach verbinden",
|
|
91
91
|
"communication_channels.profile.connect.mailboxAlreadyConnected": "Dieses Postfach ist bereits über einen anderen Anbieter verbunden. Trennen Sie es zuerst, um es mit einem anderen Anbieter erneut zu verbinden.",
|
|
92
|
+
"communication_channels.profile.connect.menu": "Kanal verbinden",
|
|
92
93
|
"communication_channels.profile.connect.notConfigured": "Dieser Anbieter ist noch nicht konfiguriert. Bitten Sie einen Administrator, die OAuth-Client-ID und das Secret unter Integrationen zu hinterlegen, bevor ein Postfach verbunden wird.",
|
|
93
94
|
"communication_channels.profile.connect.oauthFailed": "OAuth-Verbindung konnte nicht gestartet werden.",
|
|
94
95
|
"communication_channels.profile.connect.save": "Verbinden",
|
|
@@ -100,7 +101,7 @@
|
|
|
100
101
|
"communication_channels.profile.disconnect.description": "Dies entfernt die Verbindung und stoppt die Synchronisierung. Bereits importierte E-Mails bleiben in Ihren Timelines. Sie können den Kanal jederzeit erneut verbinden.",
|
|
101
102
|
"communication_channels.profile.disconnect.submitting": "Wird getrennt…",
|
|
102
103
|
"communication_channels.profile.disconnect.title": "Kanal trennen",
|
|
103
|
-
"communication_channels.profile.empty": "Sie haben noch keine verbundenen Kanäle.
|
|
104
|
+
"communication_channels.profile.empty": "Sie haben noch keine verbundenen Kanäle. Fügen Sie einen über das Menü oben auf dieser Seite hinzu.",
|
|
104
105
|
"communication_channels.profile.flash.connected": "Kanal verbunden.",
|
|
105
106
|
"communication_channels.profile.flash.connectedWithProvider": "Kanal verbunden ({provider}).",
|
|
106
107
|
"communication_channels.profile.flash.error": "Kanal konnte nicht verbunden werden.",
|
|
@@ -109,8 +110,8 @@
|
|
|
109
110
|
"communication_channels.profile.importHistory.cancel": "Abbrechen",
|
|
110
111
|
"communication_channels.profile.importHistory.description": "Ältere Nachrichten abrufen, die dieser Kanal beim Verbinden noch nicht gesehen hat. Filter schränken die Suche serverseitig ein.",
|
|
111
112
|
"communication_channels.profile.importHistory.errors.contactEmails": "Mindestens ein Eintrag ist keine gültige E-Mail-Adresse.",
|
|
112
|
-
"communication_channels.profile.importHistory.errors.maxMessages": "Wählen Sie eine Zahl zwischen 1 und
|
|
113
|
-
"communication_channels.profile.importHistory.errors.sinceDays": "Wählen Sie eine Zahl zwischen 1 und
|
|
113
|
+
"communication_channels.profile.importHistory.errors.maxMessages": "Wählen Sie eine Zahl zwischen 1 und {max} Nachrichten.",
|
|
114
|
+
"communication_channels.profile.importHistory.errors.sinceDays": "Wählen Sie eine Zahl zwischen 1 und {max} Tagen.",
|
|
114
115
|
"communication_channels.profile.importHistory.fields.contactEmails": "Nach Absender filtern (optional)",
|
|
115
116
|
"communication_channels.profile.importHistory.fields.contactEmailsHint": "Leer lassen, um alle Absender im Zeitraum zu scannen.",
|
|
116
117
|
"communication_channels.profile.importHistory.fields.contactEmailsPlaceholder": "alice@example.com, bob@example.com",
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
"communication_channels.profile.connect.imap": "Connect IMAP",
|
|
90
90
|
"communication_channels.profile.connect.imapTitle": "Connect IMAP mailbox",
|
|
91
91
|
"communication_channels.profile.connect.mailboxAlreadyConnected": "This mailbox is already connected through another provider. Disconnect it first to reconnect it with a different one.",
|
|
92
|
+
"communication_channels.profile.connect.menu": "Connect channel",
|
|
92
93
|
"communication_channels.profile.connect.notConfigured": "This provider is not configured yet. Ask an administrator to add the OAuth Client ID and Secret under Integrations before connecting a mailbox.",
|
|
93
94
|
"communication_channels.profile.connect.oauthFailed": "Could not start OAuth connection.",
|
|
94
95
|
"communication_channels.profile.connect.save": "Connect",
|
|
@@ -100,7 +101,7 @@
|
|
|
100
101
|
"communication_channels.profile.disconnect.description": "This removes the connection and stops syncing. Emails already imported stay on your timelines. You can reconnect anytime.",
|
|
101
102
|
"communication_channels.profile.disconnect.submitting": "Disconnecting…",
|
|
102
103
|
"communication_channels.profile.disconnect.title": "Disconnect channel",
|
|
103
|
-
"communication_channels.profile.empty": "You have no connected channels yet.
|
|
104
|
+
"communication_channels.profile.empty": "You have no connected channels yet. Add one using the menu at the top of this page.",
|
|
104
105
|
"communication_channels.profile.flash.connected": "Channel connected.",
|
|
105
106
|
"communication_channels.profile.flash.connectedWithProvider": "Channel connected ({provider}).",
|
|
106
107
|
"communication_channels.profile.flash.error": "Failed to connect channel.",
|
|
@@ -109,8 +110,8 @@
|
|
|
109
110
|
"communication_channels.profile.importHistory.cancel": "Cancel",
|
|
110
111
|
"communication_channels.profile.importHistory.description": "Pull older messages this channel never observed at connect-time. Filters narrow the search server-side.",
|
|
111
112
|
"communication_channels.profile.importHistory.errors.contactEmails": "One or more entries is not a valid email address.",
|
|
112
|
-
"communication_channels.profile.importHistory.errors.maxMessages": "Choose a number between 1 and
|
|
113
|
-
"communication_channels.profile.importHistory.errors.sinceDays": "Choose a number between 1 and
|
|
113
|
+
"communication_channels.profile.importHistory.errors.maxMessages": "Choose a number between 1 and {max} messages.",
|
|
114
|
+
"communication_channels.profile.importHistory.errors.sinceDays": "Choose a number between 1 and {max} days.",
|
|
114
115
|
"communication_channels.profile.importHistory.fields.contactEmails": "Filter by sender (optional)",
|
|
115
116
|
"communication_channels.profile.importHistory.fields.contactEmailsHint": "Leave empty to scan all senders in the window.",
|
|
116
117
|
"communication_channels.profile.importHistory.fields.contactEmailsPlaceholder": "alice@example.com, bob@example.com",
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
"communication_channels.profile.connect.imap": "Conectar IMAP",
|
|
90
90
|
"communication_channels.profile.connect.imapTitle": "Conectar buzón IMAP",
|
|
91
91
|
"communication_channels.profile.connect.mailboxAlreadyConnected": "Este buzón ya está conectado a través de otro proveedor. Desconéctalo primero para volver a conectarlo con uno diferente.",
|
|
92
|
+
"communication_channels.profile.connect.menu": "Conectar canal",
|
|
92
93
|
"communication_channels.profile.connect.notConfigured": "Este proveedor aún no está configurado. Pide a un administrador que añada el ID de cliente y el secreto de OAuth en Integraciones antes de conectar un buzón.",
|
|
93
94
|
"communication_channels.profile.connect.oauthFailed": "No se pudo iniciar la conexión OAuth.",
|
|
94
95
|
"communication_channels.profile.connect.save": "Conectar",
|
|
@@ -100,7 +101,7 @@
|
|
|
100
101
|
"communication_channels.profile.disconnect.description": "Esto elimina la conexión y detiene la sincronización. Los correos ya importados permanecen en tus cronologías. Puedes volver a conectarlo cuando quieras.",
|
|
101
102
|
"communication_channels.profile.disconnect.submitting": "Desconectando…",
|
|
102
103
|
"communication_channels.profile.disconnect.title": "Desconectar canal",
|
|
103
|
-
"communication_channels.profile.empty": "Aún no tienes canales conectados.
|
|
104
|
+
"communication_channels.profile.empty": "Aún no tienes canales conectados. Añade uno con el menú de la parte superior de esta página.",
|
|
104
105
|
"communication_channels.profile.flash.connected": "Canal conectado.",
|
|
105
106
|
"communication_channels.profile.flash.connectedWithProvider": "Canal conectado ({provider}).",
|
|
106
107
|
"communication_channels.profile.flash.error": "No se pudo conectar el canal.",
|
|
@@ -109,8 +110,8 @@
|
|
|
109
110
|
"communication_channels.profile.importHistory.cancel": "Cancelar",
|
|
110
111
|
"communication_channels.profile.importHistory.description": "Recupera mensajes anteriores que este canal no vio al conectarse. Los filtros restringen la búsqueda en el servidor.",
|
|
111
112
|
"communication_channels.profile.importHistory.errors.contactEmails": "Una o más entradas no es una dirección de correo válida.",
|
|
112
|
-
"communication_channels.profile.importHistory.errors.maxMessages": "Elige un número entre 1 y
|
|
113
|
-
"communication_channels.profile.importHistory.errors.sinceDays": "Elige un número entre 1 y
|
|
113
|
+
"communication_channels.profile.importHistory.errors.maxMessages": "Elige un número entre 1 y {max} mensajes.",
|
|
114
|
+
"communication_channels.profile.importHistory.errors.sinceDays": "Elige un número entre 1 y {max} días.",
|
|
114
115
|
"communication_channels.profile.importHistory.fields.contactEmails": "Filtrar por remitente (opcional)",
|
|
115
116
|
"communication_channels.profile.importHistory.fields.contactEmailsHint": "Déjalo vacío para analizar todos los remitentes en el rango.",
|
|
116
117
|
"communication_channels.profile.importHistory.fields.contactEmailsPlaceholder": "alice@example.com, bob@example.com",
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
"communication_channels.profile.connect.imap": "IMAP 연결",
|
|
90
90
|
"communication_channels.profile.connect.imapTitle": "IMAP 메일함 연결",
|
|
91
91
|
"communication_channels.profile.connect.mailboxAlreadyConnected": "이 메일함은 이미 다른 제공업체를 통해 연결되어 있습니다. 다른 제공업체로 다시 연결하려면 먼저 연결을 해제하세요.",
|
|
92
|
+
"communication_channels.profile.connect.menu": "채널 연결",
|
|
92
93
|
"communication_channels.profile.connect.notConfigured": "이 제공업체는 아직 구성되지 않았습니다. 메일함을 연결하기 전에 관리자에게 통합에서 OAuth 클라이언트 ID와 시크릿을 추가하도록 요청하세요.",
|
|
93
94
|
"communication_channels.profile.connect.oauthFailed": "OAuth 연결을 시작할 수 없습니다.",
|
|
94
95
|
"communication_channels.profile.connect.save": "연결",
|
|
@@ -100,7 +101,7 @@
|
|
|
100
101
|
"communication_channels.profile.disconnect.description": "연결을 제거하고 동기화를 중지합니다. 이미 가져온 이메일은 타임라인에 그대로 유지됩니다. 언제든지 다시 연결할 수 있습니다.",
|
|
101
102
|
"communication_channels.profile.disconnect.submitting": "연결 해제 중…",
|
|
102
103
|
"communication_channels.profile.disconnect.title": "채널 연결 해제",
|
|
103
|
-
"communication_channels.profile.empty": "아직 연결된 채널이 없습니다.
|
|
104
|
+
"communication_channels.profile.empty": "아직 연결된 채널이 없습니다. 이 페이지 상단의 메뉴에서 채널을 추가하세요.",
|
|
104
105
|
"communication_channels.profile.flash.connected": "채널이 연결되었습니다.",
|
|
105
106
|
"communication_channels.profile.flash.connectedWithProvider": "채널이 연결되었습니다 ({provider}).",
|
|
106
107
|
"communication_channels.profile.flash.error": "채널 연결에 실패했습니다.",
|
|
@@ -109,8 +110,8 @@
|
|
|
109
110
|
"communication_channels.profile.importHistory.cancel": "취소",
|
|
110
111
|
"communication_channels.profile.importHistory.description": "연결 시점에 확인되지 않았던 이전 메시지를 가져옵니다. 필터는 서버 측에서 검색 범위를 좁힙니다.",
|
|
111
112
|
"communication_channels.profile.importHistory.errors.contactEmails": "하나 이상의 항목이 유효한 이메일 주소가 아닙니다.",
|
|
112
|
-
"communication_channels.profile.importHistory.errors.maxMessages": "1에서
|
|
113
|
-
"communication_channels.profile.importHistory.errors.sinceDays": "1에서
|
|
113
|
+
"communication_channels.profile.importHistory.errors.maxMessages": "1에서 {max} 사이의 메시지 수를 선택하세요.",
|
|
114
|
+
"communication_channels.profile.importHistory.errors.sinceDays": "1에서 {max}일 사이의 일 수를 선택하세요.",
|
|
114
115
|
"communication_channels.profile.importHistory.fields.contactEmails": "발신자로 필터링 (선택 사항)",
|
|
115
116
|
"communication_channels.profile.importHistory.fields.contactEmailsHint": "기간 내 모든 발신자를 검색하려면 비워두세요.",
|
|
116
117
|
"communication_channels.profile.importHistory.fields.contactEmailsPlaceholder": "alice@example.com, bob@example.com",
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
"communication_channels.profile.connect.imap": "Połącz IMAP",
|
|
90
90
|
"communication_channels.profile.connect.imapTitle": "Połącz skrzynkę IMAP",
|
|
91
91
|
"communication_channels.profile.connect.mailboxAlreadyConnected": "Ta skrzynka jest już połączona przez innego dostawcę. Najpierw ją rozłącz, aby połączyć ją z innym dostawcą.",
|
|
92
|
+
"communication_channels.profile.connect.menu": "Połącz kanał",
|
|
92
93
|
"communication_channels.profile.connect.notConfigured": "Ten dostawca nie został jeszcze skonfigurowany. Poproś administratora o dodanie identyfikatora klienta i sekretu OAuth w sekcji Integracje przed połączeniem skrzynki.",
|
|
93
94
|
"communication_channels.profile.connect.oauthFailed": "Nie udało się rozpocząć połączenia OAuth.",
|
|
94
95
|
"communication_channels.profile.connect.save": "Połącz",
|
|
@@ -100,7 +101,7 @@
|
|
|
100
101
|
"communication_channels.profile.disconnect.description": "Spowoduje to usunięcie połączenia i zatrzymanie synchronizacji. Już zaimportowane wiadomości pozostaną na osiach czasu. Możesz połączyć ponownie w dowolnej chwili.",
|
|
101
102
|
"communication_channels.profile.disconnect.submitting": "Rozłączanie…",
|
|
102
103
|
"communication_channels.profile.disconnect.title": "Rozłącz kanał",
|
|
103
|
-
"communication_channels.profile.empty": "Nie masz jeszcze połączonych kanałów.
|
|
104
|
+
"communication_channels.profile.empty": "Nie masz jeszcze połączonych kanałów. Dodaj go za pomocą menu u góry tej strony.",
|
|
104
105
|
"communication_channels.profile.flash.connected": "Kanał połączony.",
|
|
105
106
|
"communication_channels.profile.flash.connectedWithProvider": "Kanał połączony ({provider}).",
|
|
106
107
|
"communication_channels.profile.flash.error": "Nie udało się połączyć kanału.",
|
|
@@ -109,8 +110,8 @@
|
|
|
109
110
|
"communication_channels.profile.importHistory.cancel": "Anuluj",
|
|
110
111
|
"communication_channels.profile.importHistory.description": "Pobierz starsze wiadomości, których kanał nie widział w momencie połączenia. Filtry zawężają wyszukiwanie po stronie serwera.",
|
|
111
112
|
"communication_channels.profile.importHistory.errors.contactEmails": "Co najmniej jeden wpis nie jest poprawnym adresem e-mail.",
|
|
112
|
-
"communication_channels.profile.importHistory.errors.maxMessages": "Wybierz liczbę z zakresu od 1 do
|
|
113
|
-
"communication_channels.profile.importHistory.errors.sinceDays": "Wybierz liczbę z zakresu od 1 do
|
|
113
|
+
"communication_channels.profile.importHistory.errors.maxMessages": "Wybierz liczbę z zakresu od 1 do {max} wiadomości.",
|
|
114
|
+
"communication_channels.profile.importHistory.errors.sinceDays": "Wybierz liczbę z zakresu od 1 do {max} dni.",
|
|
114
115
|
"communication_channels.profile.importHistory.fields.contactEmails": "Filtruj po nadawcy (opcjonalnie)",
|
|
115
116
|
"communication_channels.profile.importHistory.fields.contactEmailsHint": "Pozostaw puste, aby skanować wszystkich nadawców w zakresie.",
|
|
116
117
|
"communication_channels.profile.importHistory.fields.contactEmailsPlaceholder": "alice@example.com, bob@example.com",
|