@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.
Files changed (102) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js +166 -0
  3. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.js.map +7 -0
  4. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js +16 -0
  5. package/dist/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.js.map +7 -0
  6. package/dist/modules/catalog/backend/catalog/prices/create/page.js +68 -0
  7. package/dist/modules/catalog/backend/catalog/prices/create/page.js.map +7 -0
  8. package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js +25 -0
  9. package/dist/modules/catalog/backend/catalog/prices/create/page.meta.js.map +7 -0
  10. package/dist/modules/catalog/backend/catalog/prices/page.js +11 -0
  11. package/dist/modules/catalog/backend/catalog/prices/page.js.map +7 -0
  12. package/dist/modules/catalog/backend/catalog/prices/page.meta.js +16 -0
  13. package/dist/modules/catalog/backend/catalog/prices/page.meta.js.map +7 -0
  14. package/dist/modules/catalog/components/prices/PriceScopeSelectors.js +201 -0
  15. package/dist/modules/catalog/components/prices/PriceScopeSelectors.js.map +7 -0
  16. package/dist/modules/catalog/components/prices/PricesDataTable.js +273 -0
  17. package/dist/modules/catalog/components/prices/PricesDataTable.js.map +7 -0
  18. package/dist/modules/catalog/components/prices/normalizePriceRecord.js +43 -0
  19. package/dist/modules/catalog/components/prices/normalizePriceRecord.js.map +7 -0
  20. package/dist/modules/catalog/components/prices/priceFormFields.js +195 -0
  21. package/dist/modules/catalog/components/prices/priceFormFields.js.map +7 -0
  22. package/dist/modules/catalog/components/prices/useBatchLabels.js +42 -0
  23. package/dist/modules/catalog/components/prices/useBatchLabels.js.map +7 -0
  24. package/dist/modules/catalog/data/validators.js +21 -2
  25. package/dist/modules/catalog/data/validators.js.map +2 -2
  26. package/dist/modules/catalog/extension-points.js +2 -1
  27. package/dist/modules/catalog/extension-points.js.map +2 -2
  28. package/dist/modules/catalog/lib/pricing.js +37 -8
  29. package/dist/modules/catalog/lib/pricing.js.map +2 -2
  30. package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js +5 -1
  31. package/dist/modules/communication_channels/api/post/channels/[id]/import-history/route.js.map +2 -2
  32. package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js +88 -0
  33. package/dist/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.js.map +7 -0
  34. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js +17 -19
  35. package/dist/modules/communication_channels/backend/profile/communication-channels/page.js.map +2 -2
  36. package/dist/modules/communication_channels/commands/connect-credential-channel.js +7 -2
  37. package/dist/modules/communication_channels/commands/connect-credential-channel.js.map +2 -2
  38. package/dist/modules/communication_channels/commands/ingest-inbound-message.js +11 -2
  39. package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
  40. package/dist/modules/communication_channels/commands/queue-import-history.js +40 -7
  41. package/dist/modules/communication_channels/commands/queue-import-history.js.map +2 -2
  42. package/dist/modules/communication_channels/extension-points.js +1 -1
  43. package/dist/modules/communication_channels/extension-points.js.map +1 -1
  44. package/dist/modules/communication_channels/lib/connect-channel.js +33 -0
  45. package/dist/modules/communication_channels/lib/connect-channel.js.map +2 -2
  46. package/dist/modules/communication_channels/lib/import-history-limits.js +69 -0
  47. package/dist/modules/communication_channels/lib/import-history-limits.js.map +7 -0
  48. package/dist/modules/communication_channels/workers/channel-import-history.js +36 -2
  49. package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
  50. package/dist/modules/messages/api/route.js +5 -1
  51. package/dist/modules/messages/api/route.js.map +2 -2
  52. package/dist/modules/messages/commands/messages.js +3 -2
  53. package/dist/modules/messages/commands/messages.js.map +2 -2
  54. package/dist/modules/messages/data/validators.js +12 -2
  55. package/dist/modules/messages/data/validators.js.map +2 -2
  56. package/dist/modules/messages/subscribers/message-notification.js +4 -1
  57. package/dist/modules/messages/subscribers/message-notification.js.map +2 -2
  58. package/dist/modules/query_index/lib/engine.js +19 -1
  59. package/dist/modules/query_index/lib/engine.js.map +2 -2
  60. package/package.json +7 -7
  61. package/src/modules/catalog/AGENTS.md +6 -2
  62. package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.meta.ts +12 -0
  63. package/src/modules/catalog/backend/catalog/prices/[id]/edit/page.tsx +198 -0
  64. package/src/modules/catalog/backend/catalog/prices/create/page.meta.ts +23 -0
  65. package/src/modules/catalog/backend/catalog/prices/create/page.tsx +70 -0
  66. package/src/modules/catalog/backend/catalog/prices/page.meta.ts +12 -0
  67. package/src/modules/catalog/backend/catalog/prices/page.tsx +14 -0
  68. package/src/modules/catalog/components/prices/PriceScopeSelectors.tsx +243 -0
  69. package/src/modules/catalog/components/prices/PricesDataTable.tsx +320 -0
  70. package/src/modules/catalog/components/prices/normalizePriceRecord.ts +71 -0
  71. package/src/modules/catalog/components/prices/priceFormFields.tsx +228 -0
  72. package/src/modules/catalog/components/prices/useBatchLabels.ts +57 -0
  73. package/src/modules/catalog/data/validators.ts +43 -2
  74. package/src/modules/catalog/extension-points.ts +1 -0
  75. package/src/modules/catalog/i18n/de.json +72 -0
  76. package/src/modules/catalog/i18n/en.json +72 -0
  77. package/src/modules/catalog/i18n/es.json +72 -0
  78. package/src/modules/catalog/i18n/ko.json +72 -0
  79. package/src/modules/catalog/i18n/pl.json +72 -0
  80. package/src/modules/catalog/lib/pricing.ts +103 -9
  81. package/src/modules/communication_channels/api/post/channels/[id]/import-history/route.ts +6 -1
  82. package/src/modules/communication_channels/backend/profile/communication-channels/ConnectChannelMenu.tsx +120 -0
  83. package/src/modules/communication_channels/backend/profile/communication-channels/page.tsx +20 -17
  84. package/src/modules/communication_channels/commands/connect-credential-channel.ts +9 -0
  85. package/src/modules/communication_channels/commands/ingest-inbound-message.ts +17 -2
  86. package/src/modules/communication_channels/commands/queue-import-history.ts +59 -11
  87. package/src/modules/communication_channels/extension-points.ts +1 -1
  88. package/src/modules/communication_channels/i18n/de.json +4 -3
  89. package/src/modules/communication_channels/i18n/en.json +4 -3
  90. package/src/modules/communication_channels/i18n/es.json +4 -3
  91. package/src/modules/communication_channels/i18n/ko.json +4 -3
  92. package/src/modules/communication_channels/i18n/pl.json +4 -3
  93. package/src/modules/communication_channels/lib/adapter.ts +16 -2
  94. package/src/modules/communication_channels/lib/connect-channel.ts +49 -0
  95. package/src/modules/communication_channels/lib/import-history-limits.ts +109 -0
  96. package/src/modules/communication_channels/workers/channel-import-history.ts +46 -5
  97. package/src/modules/messages/api/route.ts +8 -2
  98. package/src/modules/messages/commands/messages.ts +17 -2
  99. package/src/modules/messages/data/validators.ts +26 -11
  100. package/src/modules/messages/subscribers/message-notification.ts +17 -5
  101. package/src/modules/query_index/lib/engine.ts +23 -1
  102. 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 = bodySchema.safeParse(rawBody)
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-baseline justify-between">
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
- <InjectionSpot
518
- spotId={extensionPoints.hosts.profileConnect.spotId}
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. Use one of the Connect buttons above to add a channel.',
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 > 365) {
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 365 days.',
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 > 5000) {
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 5000 messages.',
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={365}
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={5000}
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
- recipients: mapping?.assignedUserId
394
- ? [{ userId: mapping.assignedUserId, type: 'to' as const }]
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 const queueImportHistorySchema = z.object({
28
- channelId: z.string().uuid(),
29
- sinceDays: z.number().int().min(1).max(365).default(30),
30
- contactEmails: z
31
- .array(z.string().email().max(255))
32
- .max(200)
33
- .optional(),
34
- maxMessages: z.number().int().min(1).max(5000).default(1000),
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<typeof queueImportHistorySchema>
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 = queueImportHistorySchema.parse(params.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/page.tsx',
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. Verwenden Sie oben eine der Schaltflächen zum Verbinden, um einen Kanal hinzuzufügen.",
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 5000 Nachrichten.",
113
- "communication_channels.profile.importHistory.errors.sinceDays": "Wählen Sie eine Zahl zwischen 1 und 365 Tagen.",
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. Use one of the Connect buttons above to add a channel.",
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 5000 messages.",
113
- "communication_channels.profile.importHistory.errors.sinceDays": "Choose a number between 1 and 365 days.",
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. Usa uno de los botones de conexión de arriba para añadir un canal.",
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 5000 mensajes.",
113
- "communication_channels.profile.importHistory.errors.sinceDays": "Elige un número entre 1 y 365 días.",
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에서 5000 사이의 메시지 수를 선택하세요.",
113
- "communication_channels.profile.importHistory.errors.sinceDays": "1에서 365일 사이의 일 수를 선택하세요.",
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. Użyj jednego z przycisków połączenia powyżej, aby dodać kanał.",
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 5000 wiadomości.",
113
- "communication_channels.profile.importHistory.errors.sinceDays": "Wybierz liczbę z zakresu od 1 do 365 dni.",
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",