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';