fix(messaging): raise campaign recipient cap to 1000 and share constant

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.
This commit is contained in:
Félix Malfait
2026-05-28 15:18:23 +02:00
parent 532b645e53
commit 042a982e0e
5 changed files with 25 additions and 6 deletions
@@ -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,
});
@@ -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)`}
</StyledRecipientCount>
{recipientPersonIds.length >= MAX_CAMPAIGN_RECIPIENTS && (
<StyledTruncationWarning>
{t`Recipient cap of ${MAX_CAMPAIGN_RECIPIENTS} reached — any additional people selected will not receive this campaign.`}
</StyledTruncationWarning>
)}
{!domainsLoading && verifiedDomains.length === 0 && (
<StyledEmptyDomainsMessage>
{t`No verified sending domain. Add one in Settings → Emailing Domains first.`}
@@ -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 {
@@ -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;
@@ -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';