From 042a982e0e6fbc4f0c59e4823b0bac1159e62f66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 28 May 2026 15:18:23 +0200 Subject: [PATCH] fix(messaging): raise campaign recipient cap to 1000 and share constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SendCampaignCommand was using MAX_EMAIL_RECIPIENTS (=100), the cap designed for the user-mailbox compose flow. Campaigns send one message per recipient on a background worker and the server-side DTO already caps at 1000 — the frontend was just clipping the fetched person IDs earlier and silently truncating large selections (e.g. 1200 People selected → drawer showed 'Sending to 100'). - Extract MAX_CAMPAIGN_RECIPIENTS (= 1000) to twenty-shared/constants so client and server validate against the same number. - SendCampaignCommand fetches up to MAX_CAMPAIGN_RECIPIENTS person IDs. - Compose drawer shows a red warning when the cap is hit, telling the user that additional selections will be skipped. --- .../record/components/SendCampaignCommand.tsx | 11 +++++++---- .../components/SidePanelComposeCampaignPage.tsx | 12 ++++++++++++ .../dtos/send-message-campaign.input.ts | 3 +-- .../src/constants/MaxCampaignRecipients.ts | 4 ++++ packages/twenty-shared/src/constants/index.ts | 1 + 5 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 packages/twenty-shared/src/constants/MaxCampaignRecipients.ts diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/SendCampaignCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/SendCampaignCommand.tsx index 4e6d04811ec..990fcdd2502 100644 --- a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/SendCampaignCommand.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/components/SendCampaignCommand.tsx @@ -1,4 +1,4 @@ -import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants'; +import { MAX_CAMPAIGN_RECIPIENTS } from 'twenty-shared/constants'; import { CoreObjectNameSingular } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; @@ -17,13 +17,16 @@ export const SendCampaignCommand = () => { const objectNameSingular = objectMetadataItem?.nameSingular ?? null; const isPerson = objectNameSingular === CoreObjectNameSingular.Person; - // Campaign is bulk-only in v1 — selecting a single Person should still work, - // but in practice it's a tool for selections. + // Campaign is bulk-only in v1. We hard-cap recipient resolution at + // MAX_CAMPAIGN_RECIPIENTS to match the server-side validation in the + // SendMessageCampaign DTO. If the user selected more than that, only the + // first MAX_CAMPAIGN_RECIPIENTS will be fetched — the drawer surfaces the + // truncation via the selectedCount vs queuedCount delta. const { records: personRecords, loading } = useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.Person, filter: graphqlFilter ?? undefined, recordGqlFields: { id: true }, - limit: MAX_EMAIL_RECIPIENTS, + limit: MAX_CAMPAIGN_RECIPIENTS, skip: !isPerson, }); diff --git a/packages/twenty-front/src/modules/side-panel/pages/compose-campaign/components/SidePanelComposeCampaignPage.tsx b/packages/twenty-front/src/modules/side-panel/pages/compose-campaign/components/SidePanelComposeCampaignPage.tsx index b11c9708fef..aeb53d90c3c 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/compose-campaign/components/SidePanelComposeCampaignPage.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/compose-campaign/components/SidePanelComposeCampaignPage.tsx @@ -6,6 +6,7 @@ import { useCreateBlockNote } from '@blocknote/react'; import '@blocknote/react/style.css'; import { styled } from '@linaria/react'; import { t } from '@lingui/core/macro'; +import { MAX_CAMPAIGN_RECIPIENTS } from 'twenty-shared/constants'; import { IconSend } from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; import { themeCssVariables } from 'twenty-ui/theme-constants'; @@ -82,6 +83,11 @@ const StyledRecipientCount = styled.div` font-size: ${themeCssVariables.font.size.sm}; `; +const StyledTruncationWarning = styled.div` + color: ${themeCssVariables.font.color.danger}; + font-size: ${themeCssVariables.font.size.sm}; +`; + const StyledEmptyDomainsMessage = styled.div` color: ${themeCssVariables.font.color.danger}; font-size: ${themeCssVariables.font.size.sm}; @@ -175,6 +181,12 @@ export const SidePanelComposeCampaignPage = () => { {t`Sending to ${recipientPersonIds.length} recipient(s)`} + {recipientPersonIds.length >= MAX_CAMPAIGN_RECIPIENTS && ( + + {t`Recipient cap of ${MAX_CAMPAIGN_RECIPIENTS} reached — any additional people selected will not receive this campaign.`} + + )} + {!domainsLoading && verifiedDomains.length === 0 && ( {t`No verified sending domain. Add one in Settings → Emailing Domains first.`} diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/dtos/send-message-campaign.input.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/dtos/send-message-campaign.input.ts index 7f90de34536..2518cd65c31 100644 --- a/packages/twenty-server/src/modules/messaging/message-outbound-manager/dtos/send-message-campaign.input.ts +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/dtos/send-message-campaign.input.ts @@ -10,8 +10,7 @@ import { IsUUID, Length, } from 'class-validator'; - -export const MAX_CAMPAIGN_RECIPIENTS = 1000; +import { MAX_CAMPAIGN_RECIPIENTS } from 'twenty-shared/constants'; @InputType() export class SendMessageCampaignInput { diff --git a/packages/twenty-shared/src/constants/MaxCampaignRecipients.ts b/packages/twenty-shared/src/constants/MaxCampaignRecipients.ts new file mode 100644 index 00000000000..169a1bd2bac --- /dev/null +++ b/packages/twenty-shared/src/constants/MaxCampaignRecipients.ts @@ -0,0 +1,4 @@ +// Hard cap on recipients for a single message campaign. Enforced server-side +// in the SendMessageCampaign DTO and used client-side to limit the recipient +// resolution query. +export const MAX_CAMPAIGN_RECIPIENTS = 1000; diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index 92f42aa8830..c4e76430f1b 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -37,6 +37,7 @@ export { GIN_COMPATIBLE_FIELD_TYPES } from './GinCompatibleFieldTypes'; export { GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE } from './GroupByDateGranularityThatRequireTimeZone'; export { IANA_TIME_ZONES } from './IanaTimeZones'; export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMetadataTypes'; +export { MAX_CAMPAIGN_RECIPIENTS } from './MaxCampaignRecipients'; export { MAX_CUSTOM_INDEXES_PER_OBJECT } from './MaxCustomIndexesPerObject'; export { MAX_EMAIL_RECIPIENTS } from './MaxEmailRecipients'; export { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from './MultiItemFieldDefaultMaxValues';