Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/src/generated/core/api.schemas.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/src/scenes/settings/SettingsMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: <WorkflowsFrequencyCapSettings />,
keywords: ['workflows', 'frequency', 'cap', 'limit', 'fatigue', 'marketing', 'broadcast', 'email'],
},
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions posthog/api/team/team_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Comment thread
mayteio marked this conversation as resolved.
Resolved
]

def _enforce_self_serve_ceiling(self, field: str, value: int | None, ceiling: int) -> int | None:
Expand Down
1 change: 1 addition & 0 deletions products/desktop/packages/api-client/src/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
],
),
),
]
2 changes: 1 addition & 1 deletion products/workflows/backend/migrations/max_migration.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0039_workflowimport
0040_team_workflows_config_frequency_cap_hold
6 changes: 5 additions & 1 deletion products/workflows/backend/models/team_workflows_config.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)]
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions products/workflows/backend/test/test_team_workflows_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
}
Expand Down Expand Up @@ -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": {},
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,19 @@ 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",
)

assert response.status_code == status.HTTP_200_OK, response.json()
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(
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@
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'],
},
Expand Down Expand Up @@ -1595,7 +1595,7 @@
emailActions: { id: string; name: string }[],
emailTotalsByActionId: Record<string, Partial<Record<EmailMetricName, number>>>
): EmailMetricRow[] {
return emailActions.map((action) => {

Check warning on line 1598 in products/workflows/frontend/Workflows/workflowMetricsSummaryLogic.ts

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`<anonymous>` has cyclomatic complexity 15 (warn >10)
const totals = emailTotalsByActionId[action.id] || {}
const sent = totals.email_sent ?? 0
const bounced = totals.email_bounced ?? 0
Expand Down
14 changes: 14 additions & 0 deletions products/workflows/frontend/generated/api.schemas.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions products/workflows/frontend/generated/api.zod.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@
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'

export function WorkflowsFrequencyCapSettings(): JSX.Element {

Check warning on line 21 in products/workflows/frontend/scenes/settings/WorkflowsFrequencyCapSettings.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`WorkflowsFrequencyCapSettings` has cyclomatic complexity 12 (warn >10)
const { currentTeam, currentTeamLoading } = useValues(teamLogic)
const { updateCurrentTeam } = useActions(teamLogic)
const unsavedGuard = unsavedSettingsLogic({
Expand All @@ -35,18 +37,22 @@
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<number | null>(savedMaxMessages)
const [windowDays, setWindowDays] = useState<number | null>(savedWindowDays)
const [holdHours, setHoldHours] = useState<number | null>(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)
Expand Down Expand Up @@ -91,6 +97,25 @@
data-attr="workflows-frequency-cap-window-days"
/>
</LemonField.Pure>
<LemonField.Pure
className="flex-1 min-w-60"
label="Hold capped messages for up to (hours)"
info="A message over the cap waits for the person's next free slot and sends then. If no slot opens in time, it is skipped. Leave empty to skip capped messages right away."
htmlFor="workflows-frequency-cap-hold-hours"
error={holdHoursError}
>
<LemonInput
id="workflows-frequency-cap-hold-hours"
type="number"
min={1}
max={MAX_FREQUENCY_CAP_HOLD_HOURS}
value={holdHours ?? undefined}
onChange={(value) => setHoldHours(toFrequencyCapInputValue(value))}
placeholder="Don't hold"
disabledReason={restrictedReason}
data-attr="workflows-frequency-cap-hold-hours"
/>
</LemonField.Pure>
</div>
<div className="mt-4">
<LemonButton
Expand All @@ -103,12 +128,13 @@
currentTeam?.workflows_config?.capture_workflows_engagement_events ?? false,
marketing_frequency_cap_max_messages: maxMessages,
marketing_frequency_cap_window_days: windowDays,
marketing_frequency_cap_hold_hours: holdHours,
},
})
}
disabledReason={
restrictedReason ??
(maxMessagesError || windowDaysError
(maxMessagesError || windowDaysError || holdHoursError
? 'Fix the fields above to save'
: unchanged
? 'No changes to save'
Expand Down
7 changes: 7 additions & 0 deletions products/workflows/frontend/scenes/settings/frequencyCap.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
// Kept in step with the max_value on TeamWorkflowsConfigSerializer.
export const MAX_FREQUENCY_CAP_MESSAGES = 1000
export const MAX_FREQUENCY_CAP_WINDOW_DAYS = 30
export const MAX_FREQUENCY_CAP_HOLD_HOURS = 168

export function frequencyCapHoldError(value: number | null): string | undefined {
return value === null || (Number.isInteger(value) && value >= 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 {
Expand Down
21 changes: 21 additions & 0 deletions services/mcp/src/api/generated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading