(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": [
{