diff --git a/frontend/src/generated/core/api.schemas.ts b/frontend/src/generated/core/api.schemas.ts index 55427278f789..69b81ce8bfc4 100644 --- a/frontend/src/generated/core/api.schemas.ts +++ b/frontend/src/generated/core/api.schemas.ts @@ -1838,6 +1838,13 @@ export interface TeamWorkflowsConfigApi { * @nullable */ marketing_frequency_cap_window_days?: number | null + /** + * How many hours a marketing message over the frequency cap waits for a free slot before it is skipped. Null skips it right away. + * @minimum 1 + * @maximum 168 + * @nullable + */ + marketing_frequency_cap_hold_hours?: number | null } export interface TeamFeatureFlagPolicyConfigApi { diff --git a/frontend/src/scenes/settings/SettingsMap.tsx b/frontend/src/scenes/settings/SettingsMap.tsx index a64c82548496..29aa781516d1 100644 --- a/frontend/src/scenes/settings/SettingsMap.tsx +++ b/frontend/src/scenes/settings/SettingsMap.tsx @@ -1936,7 +1936,7 @@ export const SETTINGS_MAP: SettingSection[] = [ id: 'workflows-frequency-cap', title: 'Frequency cap', description: - 'The most marketing messages one person can get in a rolling number of days, across all workflows and broadcasts. Sends past the cap are skipped and the workflow moves on to the next step. Transactional messages are never capped and do not count. A workflow run without a person, for example one triggered by an event without a person profile, is not capped. Leave both fields empty to turn the cap off.', + 'The most marketing messages one person can get in a rolling number of days, across all workflows and broadcasts. Sends past the cap are skipped and the workflow moves on to the next step, unless you set a hold time. Then a send waits for the next free slot for that person. Transactional messages are never capped and do not count. A workflow run without a person, for example one triggered by an event without a person profile, is not capped. Leave both fields empty to turn the cap off.', component: , keywords: ['workflows', 'frequency', 'cap', 'limit', 'fatigue', 'marketing', 'broadcast', 'email'], }, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2307de9be1df..d0d0f1797959 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -952,6 +952,8 @@ export interface WorkflowsConfig { // Null on either disables the marketing frequency cap. marketing_frequency_cap_max_messages?: number | null marketing_frequency_cap_window_days?: number | null + // Null skips a capped marketing message at once instead of holding it for a free slot. + marketing_frequency_cap_hold_hours?: number | null } export interface ProductIntentType { diff --git a/posthog/api/team/team_config.py b/posthog/api/team/team_config.py index 757bc8eeae60..0f100e25ccaf 100644 --- a/posthog/api/team/team_config.py +++ b/posthog/api/team/team_config.py @@ -320,6 +320,16 @@ class TeamWorkflowsConfigSerializer(serializers.ModelSerializer, UserAccessContr "Set this together with marketing_frequency_cap_max_messages. Set both to null to turn the cap off." ), ) + marketing_frequency_cap_hold_hours = serializers.IntegerField( + required=False, + allow_null=True, + min_value=1, + max_value=168, + help_text=( + "How many hours a marketing message over the frequency cap waits for a free slot before it is skipped. " + "Null skips it right away." + ), + ) replay_vision_scan_rate_limit_per_day = serializers.IntegerField( required=False, @@ -382,6 +392,7 @@ class Meta: "default_email_integration_id", "marketing_frequency_cap_max_messages", "marketing_frequency_cap_window_days", + "marketing_frequency_cap_hold_hours", ] def _enforce_self_serve_ceiling(self, field: str, value: int | None, ceiling: int) -> int | None: diff --git a/products/desktop/packages/api-client/src/generated.ts b/products/desktop/packages/api-client/src/generated.ts index 0bb6eeaba5fe..8b9cddc2a8f5 100644 --- a/products/desktop/packages/api-client/src/generated.ts +++ b/products/desktop/packages/api-client/src/generated.ts @@ -20460,6 +20460,7 @@ export namespace Schemas { default_email_integration_id: number | null; marketing_frequency_cap_max_messages: number | null; marketing_frequency_cap_window_days: number | null; + marketing_frequency_cap_hold_hours: number | null; }>; export type TeamFeatureFlagPolicyConfig = Partial<{ require_tags: boolean; diff --git a/products/workflows/backend/migrations/0040_team_workflows_config_frequency_cap_hold.py b/products/workflows/backend/migrations/0040_team_workflows_config_frequency_cap_hold.py new file mode 100644 index 000000000000..01209b526dbc --- /dev/null +++ b/products/workflows/backend/migrations/0040_team_workflows_config_frequency_cap_hold.py @@ -0,0 +1,25 @@ +# Generated by Django 6.1.2 on 2026-11-02 15:22 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("workflows", "0039_workflowimport"), + ] + + operations = [ + migrations.AddField( + model_name="teamworkflowsconfig", + name="marketing_frequency_cap_hold_hours", + field=models.IntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(168), + ], + ), + ), + ] diff --git a/products/workflows/backend/migrations/max_migration.txt b/products/workflows/backend/migrations/max_migration.txt index b8a83e817ac3..2fcdef123a71 100644 --- a/products/workflows/backend/migrations/max_migration.txt +++ b/products/workflows/backend/migrations/max_migration.txt @@ -1 +1 @@ -0039_workflowimport +0040_team_workflows_config_frequency_cap_hold diff --git a/products/workflows/backend/models/team_workflows_config.py b/products/workflows/backend/models/team_workflows_config.py index 81731ce0d844..8ecf23d8b21d 100644 --- a/products/workflows/backend/models/team_workflows_config.py +++ b/products/workflows/backend/models/team_workflows_config.py @@ -1,4 +1,4 @@ -from django.core.validators import MinValueValidator +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from posthog.models.team import Team @@ -76,3 +76,7 @@ class TeamWorkflowsConfig(models.Model): # Max marketing messages per person per rolling window across all workflows; null on either means no cap. marketing_frequency_cap_max_messages = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(1)]) marketing_frequency_cap_window_days = models.IntegerField(null=True, blank=True, validators=[MinValueValidator(1)]) + # How long a capped marketing message waits for a free slot before it is skipped; null skips it at once. + marketing_frequency_cap_hold_hours = models.IntegerField( + null=True, blank=True, validators=[MinValueValidator(1), MaxValueValidator(168)] + ) diff --git a/products/workflows/backend/presentation/views/workflow_sending_rules.py b/products/workflows/backend/presentation/views/workflow_sending_rules.py index 2178e252a160..b20c8e32546a 100644 --- a/products/workflows/backend/presentation/views/workflow_sending_rules.py +++ b/products/workflows/backend/presentation/views/workflow_sending_rules.py @@ -42,6 +42,15 @@ class WorkflowSendingRulesSerializer(serializers.Serializer): help_text="The length of the rolling frequency cap window, in days. Set it together with " "marketing_frequency_cap_max_messages, or set both to null to turn the cap off.", ) + marketing_frequency_cap_hold_hours = serializers.IntegerField( + required=False, + allow_null=True, + min_value=1, + max_value=168, + help_text="How many hours a marketing message over the frequency cap waits for a free slot before it is " + "skipped. Null skips it right away. A held message sends as soon as one of the person's earlier messages " + "leaves the window.", + ) email_tracking_consent_mode = serializers.ChoiceField( choices=EMAIL_TRACKING_CONSENT_MODE_CHOICES, required=False, diff --git a/products/workflows/backend/test/test_team_workflows_config.py b/products/workflows/backend/test/test_team_workflows_config.py index 2499e3807dd3..87d2082e299d 100644 --- a/products/workflows/backend/test/test_team_workflows_config.py +++ b/products/workflows/backend/test/test_team_workflows_config.py @@ -55,6 +55,7 @@ def test_defaults_to_capture_disabled_when_no_extension_row_exists(self) -> None "default_email_integration_id": None, "marketing_frequency_cap_max_messages": None, "marketing_frequency_cap_window_days": None, + "marketing_frequency_cap_hold_hours": None, "email_utm_tags_enabled": False, "email_utm_params": {}, } @@ -150,6 +151,7 @@ def test_patch_ignores_unknown_keys_in_workflows_config(self) -> None: "default_email_integration_id": None, "marketing_frequency_cap_max_messages": None, "marketing_frequency_cap_window_days": None, + "marketing_frequency_cap_hold_hours": None, "email_utm_tags_enabled": False, "email_utm_params": {}, } diff --git a/products/workflows/backend/test/test_workflow_sending_rules.py b/products/workflows/backend/test/test_workflow_sending_rules.py index b5459cc957f9..b02cb32e507c 100644 --- a/products/workflows/backend/test/test_workflow_sending_rules.py +++ b/products/workflows/backend/test/test_workflow_sending_rules.py @@ -19,7 +19,11 @@ def test_an_admin_sets_the_cap_and_reads_it_back(self) -> None: response = self.client.patch( self._url(), - {"marketing_frequency_cap_max_messages": 1, "marketing_frequency_cap_window_days": 2}, + { + "marketing_frequency_cap_max_messages": 1, + "marketing_frequency_cap_window_days": 2, + "marketing_frequency_cap_hold_hours": 48, + }, format="json", ) @@ -27,6 +31,7 @@ def test_an_admin_sets_the_cap_and_reads_it_back(self) -> None: body = self.client.get(self._url()).json() assert body["marketing_frequency_cap_max_messages"] == 1 assert body["marketing_frequency_cap_window_days"] == 2 + assert body["marketing_frequency_cap_hold_hours"] == 48 @parameterized.expand( [ diff --git a/products/workflows/frontend/Workflows/workflowMetricsSummaryLogic.ts b/products/workflows/frontend/Workflows/workflowMetricsSummaryLogic.ts index 641bf14c32b8..fa6459141b1f 100644 --- a/products/workflows/frontend/Workflows/workflowMetricsSummaryLogic.ts +++ b/products/workflows/frontend/Workflows/workflowMetricsSummaryLogic.ts @@ -318,7 +318,7 @@ export const WORKFLOW_EMAIL_METRICS: Record< message_frequency_capped: { name: 'Frequency capped', description: - 'Total number of emails that were not sent because the person already got the most marketing messages the project allows in the cap window. The workflow moves on to the next step. Change the cap under Project settings > Workflows > Frequency cap.', + 'Total number of emails that were not sent because the person already got the most marketing messages the project allows in the cap window. When the project holds capped emails, an email counts here only if no slot opened before the hold ended. The workflow moves on to the next step. Change the cap under Project settings > Workflows > Frequency cap.', color: METRIC_COLORS['Frequency capped'], metricNames: ['message_frequency_capped'], }, diff --git a/products/workflows/frontend/generated/api.schemas.ts b/products/workflows/frontend/generated/api.schemas.ts index 95e5fe37f053..9c88c2f5c954 100644 --- a/products/workflows/frontend/generated/api.schemas.ts +++ b/products/workflows/frontend/generated/api.schemas.ts @@ -2955,6 +2955,13 @@ export interface WorkflowSendingRulesApi { * @nullable */ marketing_frequency_cap_window_days?: number | null + /** + * How many hours a marketing message over the frequency cap waits for a free slot before it is skipped. Null skips it right away. A held message sends as soon as one of the person's earlier messages leaves the window. + * @minimum 1 + * @maximum 168 + * @nullable + */ + marketing_frequency_cap_hold_hours?: number | null /** Whether tracking opens and clicks on marketing email needs the recipient's consent. 'off' follows each email step's own setting, 'opt_out' tracks unless the recipient objected, and 'opt_in' tracks only recipients who consented. Transactional email is never affected. * * * `off` - Off @@ -2984,6 +2991,13 @@ export interface PatchedWorkflowSendingRulesApi { * @nullable */ marketing_frequency_cap_window_days?: number | null + /** + * How many hours a marketing message over the frequency cap waits for a free slot before it is skipped. Null skips it right away. A held message sends as soon as one of the person's earlier messages leaves the window. + * @minimum 1 + * @maximum 168 + * @nullable + */ + marketing_frequency_cap_hold_hours?: number | null /** Whether tracking opens and clicks on marketing email needs the recipient's consent. 'off' follows each email step's own setting, 'opt_out' tracks unless the recipient objected, and 'opt_in' tracks only recipients who consented. Transactional email is never affected. * * * `off` - Off diff --git a/products/workflows/frontend/generated/api.zod.ts b/products/workflows/frontend/generated/api.zod.ts index a396abb644c7..5d635dbd4acd 100644 --- a/products/workflows/frontend/generated/api.zod.ts +++ b/products/workflows/frontend/generated/api.zod.ts @@ -3589,6 +3589,8 @@ export const workflowSendingRulesPartialUpdateBodyMarketingFrequencyCapMaxMessag export const workflowSendingRulesPartialUpdateBodyMarketingFrequencyCapWindowDaysMax = 30 +export const workflowSendingRulesPartialUpdateBodyMarketingFrequencyCapHoldHoursMax = 168 + export const WorkflowSendingRulesPartialUpdateBody = /* @__PURE__ */ zod.object({ expected_project_id: zod .number() @@ -3612,6 +3614,14 @@ export const WorkflowSendingRulesPartialUpdateBody = /* @__PURE__ */ zod.object( .describe( 'The length of the rolling frequency cap window, in days. Set it together with marketing_frequency_cap_max_messages, or set both to null to turn the cap off.' ), + marketing_frequency_cap_hold_hours: zod + .number() + .min(1) + .max(workflowSendingRulesPartialUpdateBodyMarketingFrequencyCapHoldHoursMax) + .nullish() + .describe( + "How many hours a marketing message over the frequency cap waits for a free slot before it is skipped. Null skips it right away. A held message sends as soon as one of the person's earlier messages leaves the window." + ), email_tracking_consent_mode: zod .enum(['off', 'opt_out', 'opt_in']) .describe('\* `off` - Off\n\* `opt_out` - Opt Out\n\* `opt_in` - Opt In') diff --git a/products/workflows/frontend/scenes/settings/WorkflowsFrequencyCapSettings.tsx b/products/workflows/frontend/scenes/settings/WorkflowsFrequencyCapSettings.tsx index b851ea1e56bd..4b6f93f405bb 100644 --- a/products/workflows/frontend/scenes/settings/WorkflowsFrequencyCapSettings.tsx +++ b/products/workflows/frontend/scenes/settings/WorkflowsFrequencyCapSettings.tsx @@ -9,9 +9,11 @@ import { LemonInput } from 'lib/lemon-ui/LemonInput' import { teamLogic } from 'scenes/teamLogic' import { + MAX_FREQUENCY_CAP_HOLD_HOURS, MAX_FREQUENCY_CAP_MESSAGES, MAX_FREQUENCY_CAP_WINDOW_DAYS, frequencyCapFieldError, + frequencyCapHoldError, toFrequencyCapInputValue, } from './frequencyCap' import { unsavedSettingsLogic } from './unsavedSettingsLogic' @@ -35,18 +37,22 @@ export function WorkflowsFrequencyCapSettings(): JSX.Element { currentTeam?.workflows_config?.marketing_frequency_cap_max_messages ) const savedWindowDays = toFrequencyCapInputValue(currentTeam?.workflows_config?.marketing_frequency_cap_window_days) + const savedHoldHours = toFrequencyCapInputValue(currentTeam?.workflows_config?.marketing_frequency_cap_hold_hours) const [maxMessages, setMaxMessages] = useState(savedMaxMessages) const [windowDays, setWindowDays] = useState(savedWindowDays) + const [holdHours, setHoldHours] = useState(savedHoldHours) useEffect(() => { setMaxMessages(savedMaxMessages) setWindowDays(savedWindowDays) - }, [savedMaxMessages, savedWindowDays]) + setHoldHours(savedHoldHours) + }, [savedMaxMessages, savedWindowDays, savedHoldHours]) const maxMessagesError = frequencyCapFieldError(maxMessages, windowDays, MAX_FREQUENCY_CAP_MESSAGES) const windowDaysError = frequencyCapFieldError(windowDays, maxMessages, MAX_FREQUENCY_CAP_WINDOW_DAYS) - const unchanged = maxMessages === savedMaxMessages && windowDays === savedWindowDays + const holdHoursError = frequencyCapHoldError(holdHours) + const unchanged = maxMessages === savedMaxMessages && windowDays === savedWindowDays && holdHours === savedHoldHours useEffect(() => { setHasUnsavedChanges(!unchanged) @@ -91,6 +97,25 @@ export function WorkflowsFrequencyCapSettings(): JSX.Element { data-attr="workflows-frequency-cap-window-days" /> + + setHoldHours(toFrequencyCapInputValue(value))} + placeholder="Don't hold" + disabledReason={restrictedReason} + data-attr="workflows-frequency-cap-hold-hours" + /> +
= 1 && value <= MAX_FREQUENCY_CAP_HOLD_HOURS) + ? undefined + : `Enter a whole number from 1 to ${MAX_FREQUENCY_CAP_HOLD_HOURS}, or leave it empty` +} // A cleared number input reports NaN, which must save as null to turn the cap off. export function toFrequencyCapInputValue(value: number | null | undefined): number | null { diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 331e9f1fbf88..5c895259e45a 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -91670,6 +91670,13 @@ export namespace Schemas { * @nullable */ marketing_frequency_cap_window_days?: number | null; + /** + * How many hours a marketing message over the frequency cap waits for a free slot before it is skipped. Null skips it right away. + * @minimum 1 + * @maximum 168 + * @nullable + */ + marketing_frequency_cap_hold_hours?: number | null; } export interface TeamFeatureFlagPolicyConfig { @@ -96042,6 +96049,13 @@ export namespace Schemas { * @nullable */ marketing_frequency_cap_window_days?: number | null; + /** + * How many hours a marketing message over the frequency cap waits for a free slot before it is skipped. Null skips it right away. A held message sends as soon as one of the person's earlier messages leaves the window. + * @minimum 1 + * @maximum 168 + * @nullable + */ + marketing_frequency_cap_hold_hours?: number | null; /** Whether tracking opens and clicks on marketing email needs the recipient's consent. 'off' follows each email step's own setting, 'opt_out' tracks unless the recipient objected, and 'opt_in' tracks only recipients who consented. Transactional email is never affected. * * * `off` - Off @@ -126883,6 +126897,13 @@ export namespace Schemas { * @nullable */ marketing_frequency_cap_window_days?: number | null; + /** + * How many hours a marketing message over the frequency cap waits for a free slot before it is skipped. Null skips it right away. A held message sends as soon as one of the person's earlier messages leaves the window. + * @minimum 1 + * @maximum 168 + * @nullable + */ + marketing_frequency_cap_hold_hours?: number | null; /** Whether tracking opens and clicks on marketing email needs the recipient's consent. 'off' follows each email step's own setting, 'opt_out' tracks unless the recipient objected, and 'opt_in' tracks only recipients who consented. Transactional email is never affected. * * * `off` - Off diff --git a/services/mcp/src/generated/core/api.ts b/services/mcp/src/generated/core/api.ts index beb82bec1635..0c6d44c2faa7 100644 --- a/services/mcp/src/generated/core/api.ts +++ b/services/mcp/src/generated/core/api.ts @@ -371,6 +371,8 @@ export const organizationsProjectsCreateBodyWorkflowsConfigMarketingFrequencyCap export const organizationsProjectsCreateBodyWorkflowsConfigMarketingFrequencyCapWindowDaysMax = 30 +export const organizationsProjectsCreateBodyWorkflowsConfigMarketingFrequencyCapHoldHoursMax = 168 + export const organizationsProjectsCreateBodyFeatureFlagPolicyConfigBlockedHostsItemMax = 2000 export const organizationsProjectsCreateBodyFeatureFlagPolicyConfigBlockedHostsMax = 1000 @@ -2917,6 +2919,14 @@ export const OrganizationsProjectsCreateBody = () => zod .describe( 'Length of the rolling frequency cap window in days. Set this together with marketing_frequency_cap_max_messages. Set both to null to turn the cap off.' ), + marketing_frequency_cap_hold_hours: zod + .number() + .min(1) + .max(organizationsProjectsCreateBodyWorkflowsConfigMarketingFrequencyCapHoldHoursMax) + .nullish() + .describe( + 'How many hours a marketing message over the frequency cap waits for a free slot before it is skipped. Null skips it right away.' + ), }) .optional(), feature_flag_policy_config: zod @@ -3246,6 +3256,8 @@ export const organizationsProjectsPartialUpdateBodyWorkflowsConfigMarketingFrequ export const organizationsProjectsPartialUpdateBodyWorkflowsConfigMarketingFrequencyCapWindowDaysMax = 30 +export const organizationsProjectsPartialUpdateBodyWorkflowsConfigMarketingFrequencyCapHoldHoursMax = 168 + export const organizationsProjectsPartialUpdateBodyFeatureFlagPolicyConfigBlockedHostsItemMax = 2000 export const organizationsProjectsPartialUpdateBodyFeatureFlagPolicyConfigBlockedHostsMax = 1000 @@ -5806,6 +5818,14 @@ export const OrganizationsProjectsPartialUpdateBody = () => zod .describe( 'Length of the rolling frequency cap window in days. Set this together with marketing_frequency_cap_max_messages. Set both to null to turn the cap off.' ), + marketing_frequency_cap_hold_hours: zod + .number() + .min(1) + .max(organizationsProjectsPartialUpdateBodyWorkflowsConfigMarketingFrequencyCapHoldHoursMax) + .nullish() + .describe( + 'How many hours a marketing message over the frequency cap waits for a free slot before it is skipped. Null skips it right away.' + ), }) .optional(), feature_flag_policy_config: zod diff --git a/services/mcp/src/generated/workflows/api.ts b/services/mcp/src/generated/workflows/api.ts index 065e4831fd23..8e3094ed28a5 100644 --- a/services/mcp/src/generated/workflows/api.ts +++ b/services/mcp/src/generated/workflows/api.ts @@ -1631,6 +1631,8 @@ export const workflowSendingRulesPartialUpdateBodyMarketingFrequencyCapMaxMessag export const workflowSendingRulesPartialUpdateBodyMarketingFrequencyCapWindowDaysMax = 30 +export const workflowSendingRulesPartialUpdateBodyMarketingFrequencyCapHoldHoursMax = 168 + export const WorkflowSendingRulesPartialUpdateBody = () => zod.object({ expected_project_id: zod .number() @@ -1654,6 +1656,14 @@ export const WorkflowSendingRulesPartialUpdateBody = () => zod.object({ .describe( 'The length of the rolling frequency cap window, in days. Set it together with marketing_frequency_cap_max_messages, or set both to null to turn the cap off.' ), + marketing_frequency_cap_hold_hours: zod + .number() + .min(1) + .max(workflowSendingRulesPartialUpdateBodyMarketingFrequencyCapHoldHoursMax) + .nullish() + .describe( + "How many hours a marketing message over the frequency cap waits for a free slot before it is skipped. Null skips it right away. A held message sends as soon as one of the person's earlier messages leaves the window." + ), email_tracking_consent_mode: zod .enum(['off', 'opt_out', 'opt_in']) .describe('\* `off` - Off\n\* `opt_out` - Opt Out\n\* `opt_in` - Opt In') diff --git a/services/mcp/src/tools/generated/workflows.ts b/services/mcp/src/tools/generated/workflows.ts index d5b5fda35367..46fda93b509f 100644 --- a/services/mcp/src/tools/generated/workflows.ts +++ b/services/mcp/src/tools/generated/workflows.ts @@ -897,6 +897,9 @@ const workflowsSendingRulesUpdate = (): ToolBase< if (params.marketing_frequency_cap_window_days !== undefined) { body['marketing_frequency_cap_window_days'] = params.marketing_frequency_cap_window_days } + if (params.marketing_frequency_cap_hold_hours !== undefined) { + body['marketing_frequency_cap_hold_hours'] = params.marketing_frequency_cap_hold_hours + } if (params.email_tracking_consent_mode !== undefined) { body['email_tracking_consent_mode'] = params.email_tracking_consent_mode } diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/project-settings-update.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/project-settings-update.json index f27c46288b23..b0424d99fd77 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/project-settings-update.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/project-settings-update.json @@ -4440,6 +4440,19 @@ "description": "Whether new email steps and broadcasts start with UTM tags on their links. Each email can still change it. Existing emails change only through apply_utm_defaults.", "type": "boolean" }, + "marketing_frequency_cap_hold_hours": { + "anyOf": [ + { + "maximum": 168, + "minimum": 1, + "type": "number" + }, + { + "type": "null" + } + ], + "description": "How many hours a marketing message over the frequency cap waits for a free slot before it is skipped. Null skips it right away." + }, "marketing_frequency_cap_max_messages": { "anyOf": [ { diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-sending-rules-update.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-sending-rules-update.json index f8caed8f4a8d..a0f4e927bf85 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-sending-rules-update.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/workflows-sending-rules-update.json @@ -14,6 +14,19 @@ "description": "Optional. The project_id from the rules being changed. The update is rejected when it does not match the current project, so a change never lands in a project the user did not look at.", "type": "number" }, + "marketing_frequency_cap_hold_hours": { + "anyOf": [ + { + "maximum": 168, + "minimum": 1, + "type": "number" + }, + { + "type": "null" + } + ], + "description": "How many hours a marketing message over the frequency cap waits for a free slot before it is skipped. Null skips it right away. A held message sends as soon as one of the person's earlier messages leaves the window." + }, "marketing_frequency_cap_max_messages": { "anyOf": [ {