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