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
72 changes: 57 additions & 15 deletions nodejs/src/cdp/services/messaging/email-suppression.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,21 +300,48 @@ describe('EmailSuppressionService', () => {
)
})

describe('transactional email to a complaint address', () => {
describe('transactional email to a suppressed address', () => {
it.each([
['a complaint alone lets it through', ['complaint'], false],
['soft bounces up to the threshold let it through', ['soft', 'soft', 'soft'], false],
['soft bounces after a complaint let it through', ['complaint', 'soft', 'soft', 'soft'], false],
['a hard bounce blocks it', ['hard'], true],
['a hard bounce after the complaint blocks it', ['complaint', 'hard'], true],
['a hard bounce before the complaint blocks it', ['hard', 'complaint'], true],
['soft bounces up to the threshold block it', ['complaint', 'soft', 'soft', 'soft'], true],
['soft bounces below the threshold let it through', ['complaint', 'soft', 'soft'], false],
['a delivery resets the soft bounces', ['complaint', 'soft', 'soft', 'delivery', 'soft'], false],
['a hard bounce after soft bounces blocks it', ['soft', 'soft', 'soft', 'hard'], true],
['a hard bounce on a manual entry blocks it', ['manual', 'hard'], true],
['a complaint after a legacy hard bounce still blocks it', ['legacy-hard', 'complaint'], true],
['soft bounces after a legacy hard bounce still block it', ['legacy-hard', 'soft', 'soft', 'soft'], true],
] as const)('%s', async (_label, steps, blocked) => {
process.env.EMAIL_SUPPRESSION_TRANSIENT_BOUNCE_THRESHOLD = '3'
const svc = new EmailSuppressionService(hub.postgres, emailSuppressionConfigFromEnv())
const email = 'complained@example.com'
const email = 'suppressed@example.com'

for (const step of steps) {
if (step === 'complaint') {
if (step === 'manual') {
await hub.postgres.query(
PostgresUse.COMMON_WRITE,
`INSERT INTO posthog_messagesuppression
(id, team_id, identifier, source, reason, transient_bounce_count,
suppressed, suppressed_at, deleted, created_at, updated_at)
VALUES (gen_random_uuid(), $1, $2, 'MANUAL', 'test row', 0,
true, NOW(), false, NOW(), NOW())`,
[team.id, email],
'test-insert-manual-suppression'
)
} else if (step === 'legacy-hard') {
// A row written before `hard_bounced_at` existed: the mark is only in the reason.
await hub.postgres.query(
PostgresUse.COMMON_WRITE,
`INSERT INTO posthog_messagesuppression
(id, team_id, identifier, source, reason, transient_bounce_count,
suppressed, suppressed_at, deleted, created_at, updated_at)
VALUES (gen_random_uuid(), $1, $2, 'BOUNCE', 'Auto-suppressed after a hard bounce', 0,
true, NOW(), false, NOW(), NOW())`,
[team.id, email],
'test-insert-legacy-hard-suppression'
)
} else if (step === 'complaint') {
await svc.recordComplaints(team.id, [email], 'abuse')
} else if (step === 'hard') {
await svc.recordHardBounces(team.id, [email], 'smtp; 550 5.1.1 user unknown')
Expand Down Expand Up @@ -372,13 +399,28 @@ describe('EmailSuppressionService', () => {

describe('isSuppressed', () => {
it.each([
['a bounce blocks marketing email', 'BOUNCE', false, true],
['a bounce blocks transactional email', 'BOUNCE', true, true],
['a manual entry blocks transactional email', 'MANUAL', true, true],
['a complaint blocks marketing email', 'COMPLAINT', false, true],
['a complaint lets transactional email through', 'COMPLAINT', true, false],
['an address that is not on the list is not blocked', null, false, false],
] as const)('%s', async (_label, source, transactional, expected) => {
['a bounce blocks marketing email', 'BOUNCE', 'test row', false, true],
['a soft-bounce suppression lets transactional email through', 'BOUNCE', 'test row', true, false],
[
'a hard bounce from before hard_bounced_at blocks transactional email',
'BOUNCE',
'Auto-suppressed after a hard bounce',
true,
true,
],
['a manual entry blocks marketing email', 'MANUAL', 'test row', false, true],
['a manual entry lets transactional email through', 'MANUAL', 'test row', true, false],
[
'an imported Customer.io invalid address blocks transactional email',
'BOUNCE',
'Imported from Customer.io (invalid address)',
true,
true,
],
['a complaint blocks marketing email', 'COMPLAINT', 'test row', false, true],
['a complaint lets transactional email through', 'COMPLAINT', 'test row', true, false],
['an address that is not on the list is not blocked', null, null, false, false],
] as const)('%s', async (_label, source, reason, transactional, expected) => {
const svc = new EmailSuppressionService(hub.postgres, emailSuppressionConfigFromEnv())
const email = 'listed@example.com'
if (source) {
Expand All @@ -387,9 +429,9 @@ describe('EmailSuppressionService', () => {
`INSERT INTO posthog_messagesuppression
(id, team_id, identifier, source, reason, transient_bounce_count,
suppressed, suppressed_at, deleted, created_at, updated_at)
VALUES (gen_random_uuid(), $1, $2, $3, 'test row', 0,
VALUES (gen_random_uuid(), $1, $2, $3, $4, 0,
true, NOW(), false, NOW(), NOW())`,
[team.id, email, source],
[team.id, email, source, reason],
'test-insert-suppression'
)
}
Expand Down
91 changes: 54 additions & 37 deletions nodejs/src/cdp/services/messaging/email-suppression.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,25 @@ export function emailSuppressionConfigFromEnv(): EmailSuppressionConfig {
}

export type SuppressionCheckOptions = {
// A spam complaint means the recipient doesn't want our mail, not that the address can't receive it.
// Transactional email (login codes, password resets) still goes out to it. Bounce and manual entries block it.
// Transactional email (invoices, login codes, password resets) is blocked only by a hard bounce, because
// only then is the address proven dead. A complaint, a manual entry or a full inbox (soft bounces) lets it through.
transactional?: boolean
}

// An active suppression. A COMPLAINT row keeps its source when the address later bounces, so its bounce
// count decides whether it blocks transactional email: it does once the count reaches the soft-bounce
// threshold. A hard bounce sets the count to the threshold.
// An active suppression. It blocks transactional email only when the address hard-bounced.
type SuppressionEntry = { blocksTransactional: boolean }

const HARD_BOUNCE_REASON = 'Auto-suppressed after a hard bounce'

// Reasons that mark a dead address on rows written before `hard_bounced_at` existed: our own hard-bounce
// rows, and the two Customer.io lists the importer maps to BOUNCE. Both writers now set the column, so
// this list is closed and only covers rows already on disk.
const LEGACY_HARD_BOUNCE_REASONS = [
HARD_BOUNCE_REASON,
'Imported from Customer.io (bounced)',
'Imported from Customer.io (invalid address)',
]

/**
* Per-team email suppression list, backed by `posthog_messagesuppression`.
*
Expand Down Expand Up @@ -125,10 +134,16 @@ export class EmailSuppressionService {
const diag = diagnostic ? diagnostic.slice(0, DIAGNOSTIC_MAX_LENGTH) : null
const reason = `Auto-suppressed after ${this.threshold} consecutive soft bounces`

// Build a single multi-row upsert. Params: teamId, reason, threshold, diag, then one
// identifier per row.
// Build a single multi-row upsert. Params: teamId, reason, threshold, diag, legacy reasons,
// then one identifier per row.
const valueClauses: string[] = []
const params: (number | string | null)[] = [teamId, reason, this.threshold, diag]
const params: (number | string | string[] | null)[] = [
teamId,
reason,
this.threshold,
diag,
LEGACY_HARD_BOUNCE_REASONS,
]
identifiers.forEach((identifier, i) => {
const p = params.length + 1 + i
// gen_random_uuid() for the id, mirroring RecipientsManagerService.optOut. First bounce
Expand Down Expand Up @@ -164,6 +179,12 @@ export class EmailSuppressionService {
WHEN posthog_messagesuppression.source NOT IN ('MANUAL', 'COMPLAINT')
AND posthog_messagesuppression.transient_bounce_count + 1 >= $3 THEN $2
ELSE posthog_messagesuppression.reason END,
-- The reason update above replaces the text that an older row carries its hard-bounce
-- mark in. Move the mark to the column first, so the row keeps blocking transactional email.
hard_bounced_at = COALESCE(
posthog_messagesuppression.hard_bounced_at,
CASE WHEN posthog_messagesuppression.source = 'BOUNCE'
AND posthog_messagesuppression.reason = ANY($5) THEN NOW() END),
-- A provably-undeliverable address coming back over threshold un-deletes itself.
deleted = CASE
WHEN posthog_messagesuppression.source <> 'MANUAL'
Expand Down Expand Up @@ -196,23 +217,22 @@ export class EmailSuppressionService {
}

const diag = diagnostic ? diagnostic.slice(0, DIAGNOSTIC_MAX_LENGTH) : null
const reason = 'Auto-suppressed after a hard bounce'

// Params: teamId, reason, diag, threshold, then one identifier per row.
// Params: teamId, reason, diag, then one identifier per row.
const valueClauses: string[] = []
const params: (number | string | null)[] = [teamId, reason, diag, this.threshold]
const params: (number | string | null)[] = [teamId, HARD_BOUNCE_REASON, diag]
identifiers.forEach((identifier, i) => {
const p = params.length + 1 + i
valueClauses.push(
`(gen_random_uuid(), $1, $${p}, 'BOUNCE', $2, 0, NOW(), $3, true, NOW(), false, NOW(), NOW())`
`(gen_random_uuid(), $1, $${p}, 'BOUNCE', $2, 0, NOW(), $3, true, NOW(), false, NOW(), NOW(), NOW())`
)
})
params.push(...identifiers)

const query = `
INSERT INTO posthog_messagesuppression
(id, team_id, identifier, source, reason, transient_bounce_count, last_bounce_at,
last_bounce_diagnostic, suppressed, suppressed_at, deleted, created_at, updated_at)
last_bounce_diagnostic, suppressed, suppressed_at, deleted, created_at, updated_at,
hard_bounced_at)
VALUES ${valueClauses.join(', ')}
ON CONFLICT (team_id, identifier) DO UPDATE SET
last_bounce_at = NOW(),
Expand All @@ -225,12 +245,9 @@ export class EmailSuppressionService {
WHEN posthog_messagesuppression.source = 'MANUAL' THEN posthog_messagesuppression.suppressed_at
WHEN posthog_messagesuppression.suppressed = false THEN NOW()
ELSE posthog_messagesuppression.suppressed_at END,
-- A COMPLAINT row keeps its source, so the count records the bounce. At the threshold the
-- row also blocks transactional email, because the address can't receive mail.
transient_bounce_count = CASE
WHEN posthog_messagesuppression.source = 'COMPLAINT'
THEN GREATEST(posthog_messagesuppression.transient_bounce_count, $4)
ELSE posthog_messagesuppression.transient_bounce_count END,
-- Set on every row, manual and complaint entries included: the address can't receive
-- mail, so transactional email must stop too.
hard_bounced_at = NOW(),
-- A complaint outranks a bounce as the reason a row exists, so it keeps its reason
-- here the way a manual entry does. This upsert never reassigns the source, so
-- overwriting the reason on a COMPLAINT row would leave the two contradicting each
Expand Down Expand Up @@ -274,9 +291,9 @@ export class EmailSuppressionService {
? `Auto-suppressed after a spam complaint (feedback type: ${feedbackType.slice(0, 100)})`
: 'Auto-suppressed after a spam complaint'

// Params: teamId, reason, threshold, then one identifier per row.
// Params: teamId, reason, legacy reasons, then one identifier per row.
const valueClauses: string[] = []
const params: (number | string | null)[] = [teamId, reason, this.threshold]
const params: (number | string | string[] | null)[] = [teamId, reason, LEGACY_HARD_BOUNCE_REASONS]
identifiers.forEach((identifier, i) => {
const p = params.length + 1 + i
valueClauses.push(
Expand All @@ -296,14 +313,6 @@ export class EmailSuppressionService {
source = CASE
WHEN posthog_messagesuppression.source = 'MANUAL' THEN posthog_messagesuppression.source
ELSE 'COMPLAINT' END,
-- An address that a bounce already suppressed can't receive mail, so the re-keyed row
-- keeps a count at the threshold and still blocks transactional email.
transient_bounce_count = CASE
WHEN posthog_messagesuppression.source = 'BOUNCE'
AND posthog_messagesuppression.suppressed
AND NOT posthog_messagesuppression.deleted
THEN GREATEST(posthog_messagesuppression.transient_bounce_count, $3)
ELSE posthog_messagesuppression.transient_bounce_count END,
suppressed = CASE
WHEN posthog_messagesuppression.source = 'MANUAL' THEN posthog_messagesuppression.suppressed
ELSE true END,
Expand All @@ -314,6 +323,12 @@ export class EmailSuppressionService {
reason = CASE
WHEN posthog_messagesuppression.source = 'MANUAL' THEN posthog_messagesuppression.reason
ELSE EXCLUDED.reason END,
-- The reason update above replaces the text that an older row carries its hard-bounce
-- mark in. Move the mark to the column first, so the row keeps blocking transactional email.
hard_bounced_at = COALESCE(
posthog_messagesuppression.hard_bounced_at,
CASE WHEN posthog_messagesuppression.source = 'BOUNCE'
AND posthog_messagesuppression.reason = ANY($3) THEN NOW() END),
-- A soft-deleted row comes back: the complaint is fresh evidence the recipient
-- must not be mailed.
deleted = CASE
Expand All @@ -334,8 +349,7 @@ export class EmailSuppressionService {

/**
* Record successful deliveries. Resets the consecutive-bounce counter for auto (non-manual)
* entries that haven't yet been suppressed, so a transient outage never accumulates. COMPLAINT
* entries reset too, because their counter decides whether they block transactional email.
* entries that haven't yet been suppressed, so a transient outage never accumulates.
*
* `deliveryTimestamp` guards against out-of-order events: SES delivery and bounce notifications
* for different sends can arrive in any order, and a late delivery for an older send must not
Expand All @@ -358,9 +372,7 @@ export class EmailSuppressionService {
WHERE team_id = $1
AND identifier = ANY($2)
AND source <> 'MANUAL'
-- A COMPLAINT row stays suppressed, but its count decides whether it blocks transactional
-- email, and a delivery proves the address works again.
AND (suppressed = false OR source = 'COMPLAINT')
AND suppressed = false
AND transient_bounce_count > 0
AND (last_bounce_at IS NULL OR ($3::timestamptz IS NOT NULL AND last_bounce_at < $3::timestamptz))
`
Expand Down Expand Up @@ -402,15 +414,20 @@ export class EmailSuppressionService {

const entries = new Map<string, SuppressionEntry>()
for (const [teamId, identifiers] of byTeam) {
// The reason check covers rows that hard-bounced before `hard_bounced_at` existed.
const result = await this.postgres.query<{ identifier: string; blocks_transactional: boolean }>(
PostgresUse.COMMON_READ,
`SELECT identifier, (source <> 'COMPLAINT' OR transient_bounce_count >= $3) AS blocks_transactional
`SELECT identifier,
-- A soft-bounce row carries no reason until it crosses the threshold, and
-- NULL = ANY(...) is NULL, so COALESCE keeps the column a real boolean.
COALESCE(hard_bounced_at IS NOT NULL OR (source = 'BOUNCE' AND reason = ANY($3)), false)
AS blocks_transactional
FROM posthog_messagesuppression
WHERE team_id = $1
AND identifier = ANY($2)
AND suppressed = true
AND deleted = false`,
[teamId, identifiers, this.threshold],
[teamId, identifiers, LEGACY_HARD_BOUNCE_REASONS],
'loadSuppressed'
)
for (const row of result.rows) {
Expand Down
Loading
Loading