feat(messaging): Unsubscribe links (#37679)

This commit is contained in:
Ben White
2025-09-08 09:17:10 +02:00
committed by GitHub
parent 611dc6c195
commit e1e8bc3c9d
41 changed files with 658 additions and 322 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 245 KiB

View File

@@ -144,6 +144,11 @@ function NativeEmailIntegrationChoice({
const integrationsOfKind = integrations?.filter((x) => x.kind === 'email')
const onChangeIntegration = (integrationId: number): void => {
if (integrationId === -1) {
// Open new integration modal
window.open(urls.messaging('channels'), '_blank')
return
}
const integration = integrationsOfKind?.find((x) => x.id === integrationId)
onChange({
integrationId,
@@ -173,15 +178,31 @@ function NativeEmailIntegrationChoice({
return (
<>
<LemonSelect
className="m-1"
className="m-1 flex-1"
type="tertiary"
placeholder="Choose email sender"
loading={integrationsLoading}
options={(integrationsOfKind || []).map((integration) => ({
label: integration.display_name,
value: integration.id,
}))}
options={[
{
title: 'Email senders',
options: (integrationsOfKind || []).map((integration) => ({
label: integration.display_name,
value: integration.id,
})),
},
{
options: [
{
label: 'Add new email sender',
icon: <IconExternal />,
value: -1,
},
],
},
]}
value={value?.integrationId}
size="small"
fullWidth
onChange={onChangeIntegration}
/>
</>
@@ -246,47 +267,48 @@ function NativeEmailTemplaterForm({ mode }: { mode: EmailEditorMode }): JSX.Elem
</LemonField>
))}
{isMessagingProductEnabled && (
<div className="flex gap-2 items-center m-2">
<LemonLabel>Start from a template (optional)</LemonLabel>
<LemonSelect
size="small"
placeholder="Choose template"
loading={templatesLoading}
value={appliedTemplate?.id}
options={templates.map((template) => ({
label: template.name,
value: template.id,
}))}
onChange={(id) => {
const template = templates.find((t) => t.id === id)
if (template) {
applyTemplate(template)
}
}}
data-attr="email-template-selector"
disabledReason={templates.length > 0 ? undefined : 'No templates created yet'}
/>
</div>
)}
{mode === 'full' ? (
<EmailEditor
ref={(r) => setEmailEditorRef(r)}
onReady={() => onEmailEditorReady()}
minHeight={20}
options={{
mergeTags,
displayMode: 'email',
features: {
preview: true,
imageEditor: true,
stockImages: false,
},
projectId: unlayerEditorProjectId,
customJS: isMessagingProductEnabled ? [unsubscribeLinkToolCustomJs] : [],
}}
/>
<>
{isMessagingProductEnabled && (
<div className="flex gap-2 items-center px-2 py-1 border-b">
<span className="flex-1">Start from a template (optional)</span>
<LemonSelect
size="xsmall"
placeholder="Choose template"
loading={templatesLoading}
value={appliedTemplate?.id}
options={templates.map((template) => ({
label: template.name,
value: template.id,
}))}
onChange={(id) => {
const template = templates.find((t) => t.id === id)
if (template) {
applyTemplate(template)
}
}}
data-attr="email-template-selector"
disabledReason={templates.length > 0 ? undefined : 'No templates created yet'}
/>
</div>
)}
<EmailEditor
ref={(r) => setEmailEditorRef(r)}
onReady={() => onEmailEditorReady()}
minHeight={20}
options={{
mergeTags,
displayMode: 'email',
features: {
preview: true,
imageEditor: true,
stockImages: false,
},
projectId: unlayerEditorProjectId,
customJS: isMessagingProductEnabled ? [unsubscribeLinkToolCustomJs] : [],
}}
/>
</>
) : (
<LemonField name="html" className="flex relative flex-col">
{({ value }) => (

View File

@@ -89,6 +89,7 @@
"ioredis": "^4.27.6",
"ipaddr.js": "^2.1.0",
"js-big-decimal": "^2.2.0",
"jsonwebtoken": "^9.0.2",
"kafkajs": "^2.2.0",
"kafkajs-snappy": "^1.1.0",
"liquidjs": "^10.21.1",
@@ -135,6 +136,7 @@
"@types/generic-pool": "^3.1.9",
"@types/ioredis": "^4.26.4",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/kafkajs-snappy": "^1.0.0",
"@types/lodash": "^4.17.16",
"@types/long": "4.x.x",

View File

@@ -19,6 +19,7 @@ import { HogFunctionTemplateManagerService } from './services/managers/hog-funct
import { RecipientsManagerService } from './services/managers/recipients-manager.service'
import { EmailTrackingService } from './services/messaging/email-tracking.service'
import { RecipientPreferencesService } from './services/messaging/recipient-preferences.service'
import { RecipientTokensService } from './services/messaging/recipient-tokens.service'
import { HogFunctionMonitoringService } from './services/monitoring/hog-function-monitoring.service'
import { HogWatcherService, HogWatcherState } from './services/monitoring/hog-watcher.service'
import { NativeDestinationExecutorService } from './services/native-destination-executor.service'
@@ -45,6 +46,7 @@ export class CdpApi {
private cdpSourceWebhooksConsumer: CdpSourceWebhooksConsumer
private emailTrackingService: EmailTrackingService
private recipientPreferencesService: RecipientPreferencesService
private recipientTokensService: RecipientTokensService
constructor(private hub: Hub) {
this.hogFunctionManager = new HogFunctionManagerService(hub)
@@ -54,6 +56,7 @@ export class CdpApi {
this.hogExecutor = new HogExecutorService(hub)
this.recipientPreferencesService = new RecipientPreferencesService(this.recipientsManager)
this.recipientTokensService = new RecipientTokensService(hub)
this.hogFlowExecutor = new HogFlowExecutorService(
hub,
this.hogExecutor,
@@ -109,6 +112,8 @@ export class CdpApi {
router.patch('/api/projects/:team_id/hog_functions/:id/status', asyncHandler(this.patchFunctionStatus()))
router.get('/api/hog_functions/states', asyncHandler(this.getFunctionStates()))
router.get('/api/hog_function_templates', this.getHogFunctionTemplates)
router.post('/api/messaging/generate_preferences_token', asyncHandler(this.generatePreferencesToken()))
router.get('/api/messaging/validate_preferences_token/:token', asyncHandler(this.validatePreferencesToken()))
router.post('/public/webhooks/:webhook_id', asyncHandler(this.postWebhook()))
router.get('/public/webhooks/:webhook_id', asyncHandler(this.getWebhook()))
router.get('/public/m/pixel', asyncHandler(this.getEmailTrackingPixel()))
@@ -573,4 +578,47 @@ export class CdpApi {
async (req: ModifiedRequest, res: express.Response): Promise<any> => {
await this.emailTrackingService.handleEmailTrackingRedirect(req, res)
}
private generatePreferencesToken =
() =>
(req: ModifiedRequest, res: express.Response): any => {
const { team_id, identifier } = req.body
if (!team_id || !identifier) {
return res.status(400).json({ error: 'Team ID and identifier are required' })
}
const token = this.recipientTokensService.generatePreferencesToken({
team_id,
identifier,
})
return res.status(200).json({ token })
}
private validatePreferencesToken =
() =>
(req: ModifiedRequest, res: express.Response): any => {
try {
const { token } = req.params
if (!token) {
return res.status(400).json({ error: 'Token is required' })
}
const result = this.recipientTokensService.validatePreferencesToken(token)
if (!result.valid) {
return res.status(400).json({ error: 'Invalid or expired token' })
}
return res.status(200).json({
valid: result.valid,
team_id: result.team_id,
identifier: result.identifier,
})
} catch (error) {
logger.error('[CdpApi] Error validating preferences token', error)
return res.status(500).json({ error: 'Failed to validate token' })
}
}
}

View File

@@ -34,6 +34,7 @@ import { convertToHogFunctionFilterGlobal, filterFunctionInstrumented } from '..
import { createInvocation, createInvocationResult } from '../utils/invocation-utils'
import { HogInputsService } from './hog-inputs.service'
import { EmailService } from './messaging/email.service'
import { RecipientTokensService } from './messaging/recipient-tokens.service'
const cdpHttpRequests = new Counter({
name: 'cdp_http_requests',
@@ -115,8 +116,10 @@ export type HogExecutorExecuteAsyncOptions = HogExecutorExecuteOptions & {
export class HogExecutorService {
private hogInputsService: HogInputsService
private emailService: EmailService
private recipientTokensService: RecipientTokensService
constructor(private hub: Hub) {
this.recipientTokensService = new RecipientTokensService(hub)
this.hogInputsService = new HogInputsService(hub)
this.emailService = new EmailService(hub)
}
@@ -377,6 +380,14 @@ export class HogExecutorService {
message: sanitizeLogMessage(args, sensitiveValues),
})
},
generateMessagingPreferencesUrl: (identifier): string | null => {
return identifier && typeof identifier === 'string'
? this.recipientTokensService.generatePreferencesUrl({
team_id: invocation.teamId,
identifier,
})
: null
},
postHogCapture: (event) => {
const distinctId = event.distinct_id || globals.event?.distinct_id
const eventName = event.event

View File

@@ -1,3 +1,5 @@
import '~/tests/helpers/mocks/date.mock'
import { DateTime } from 'luxon'
import { getFirstTeam, resetTestDatabase } from '~/tests/helpers/sql'
@@ -17,6 +19,7 @@ describe('Hog Inputs', () => {
beforeEach(async () => {
await resetTestDatabase()
hub = await createHub()
hub.SITE_URL = 'http://localhost:8000'
team = await getFirstTeam(hub)
const fixedTime = DateTime.fromObject({ year: 2025, month: 1, day: 1 }, { zone: 'UTC' })
@@ -167,5 +170,25 @@ describe('Hog Inputs', () => {
expect(inputs.oauth).toMatchInlineSnapshot(`null`)
})
it('should add unsubscribe url if email input is present', async () => {
hogFunction.inputs = {
email: {
templating: 'liquid',
value: {
to: { email: '{{person.properties.email}}' },
html: '<div>Unsubscribe here <a href="{{unsubscribe_url}}">here</a></div>',
},
},
}
hogFunction.inputs_schema = [{ key: 'email', type: 'native_email', required: true, templating: true }]
const inputs = await hogInputsService.buildInputs(hogFunction, globals)
expect(inputs.email.to.email).toEqual('test@posthog.com')
expect(inputs.email.html).toEqual(
`<div>Unsubscribe here <a href="http://localhost:8000/messaging-preferences/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0ZWFtX2lkIjoyLCJpZGVudGlmaWVyIjoidGVzdEBwb3N0aG9nLmNvbSIsImlhdCI6MTczNTY4OTYwMCwiZXhwIjoxNzM2Mjk0NDAwLCJhdWQiOiJwb3N0aG9nOm1lc3NhZ2luZzpzdWJzY3JpcHRpb25fcHJlZmVyZW5jZXMifQ.pBh-COzTEyApuxe8J5sViPanp1lV1IClepOTVFZNhIs/">here</a></div>`
)
})
})
})

View File

@@ -1,16 +1,22 @@
import { convertHogToJS } from '@posthog/hogvm'
import { ACCESS_TOKEN_PLACEHOLDER } from '~/config/constants'
import { CyclotronInputType } from '~/schema/cyclotron'
import { Hub } from '~/types'
import { HogFunctionInvocationGlobals, HogFunctionInvocationGlobalsWithInputs, HogFunctionType } from '../types'
import { execHog } from '../utils/hog-exec'
import { LiquidRenderer } from '../utils/liquid'
import { RecipientTokensService } from './messaging/recipient-tokens.service'
export const EXTEND_OBJECT_KEY = '$$_extend_object'
export class HogInputsService {
constructor(private hub: Hub) {}
private recipientTokensService: RecipientTokensService
constructor(private hub: Hub) {
this.recipientTokensService = new RecipientTokensService(hub)
}
public async buildInputs(
hogFunction: HogFunctionType,
@@ -34,6 +40,36 @@ export class HogInputsService {
inputs: {},
}
const _formatInput = async (input: CyclotronInputType, key: string): Promise<any> => {
const templating = input.templating ?? 'hog'
if (templating === 'liquid') {
return formatLiquidInput(input.value, newGlobals, key)
}
if (templating === 'hog' && input?.bytecode) {
return await formatHogInput(input.bytecode, newGlobals, key)
}
return input.value
}
// Add unsubscribe url if we have an email input here
const emailInputSchema = hogFunction.inputs_schema?.find((input) =>
['native_email', 'email'].includes(input.type)
)
const emailInput = hogFunction.inputs?.[emailInputSchema?.key ?? '']
if (emailInputSchema && emailInput) {
// If we have an email value then we template it out to get the email address
const emailValue = await _formatInput(emailInput, emailInputSchema.key)
if (emailValue?.to?.email) {
newGlobals.unsubscribe_url = this.recipientTokensService.generatePreferencesUrl({
team_id: hogFunction.team_id,
identifier: emailValue.to.email,
})
}
}
const orderedInputs = Object.entries(inputs ?? {}).sort(([_, input1], [__, input2]) => {
return (input1?.order ?? -1) - (input2?.order ?? -1)
})
@@ -43,15 +79,7 @@ export class HogInputsService {
continue
}
newGlobals.inputs[key] = input.value
const templating = input.templating ?? 'hog'
if (templating === 'liquid') {
newGlobals.inputs[key] = formatLiquidInput(input.value, newGlobals, key)
} else if (templating === 'hog' && input?.bytecode) {
newGlobals.inputs[key] = await formatHogInput(input.bytecode, newGlobals, key)
}
newGlobals.inputs[key] = await _formatInput(input, key)
}
return newGlobals.inputs

View File

@@ -13,7 +13,7 @@ import {
import { HogExecutorService } from '../../hog-executor.service'
import { HogFunctionTemplateManagerService } from '../../managers/hog-function-template-manager.service'
import { RecipientPreferencesService } from '../../messaging/recipient-preferences.service'
import { findContinueAction } from '../hogflow-utils'
import { actionIdForLogging, findContinueAction } from '../hogflow-utils'
import { ActionHandler, ActionHandlerResult } from './action.interface'
type FunctionActionType = 'function' | 'function_email' | 'function_sms'
@@ -40,7 +40,7 @@ export class HogFunctionHandler implements ActionHandler {
result.logs.push({
level: log.level,
timestamp: log.timestamp,
message: `[Action:${action.id}] ${log.message}`,
message: `${actionIdForLogging(action)} ${log.message}`,
})
})

View File

@@ -18,6 +18,10 @@ export class TriggerHandler implements ActionHandler {
filterGlobals: invocation.filterGlobals,
})
if (filterResults.error) {
throw new Error(filterResults.error as string)
}
if (!filterResults.match) {
return { finished: true }
}

View File

@@ -27,15 +27,10 @@ import { HogFunctionHandler } from './actions/hog_function'
import { RandomCohortBranchHandler } from './actions/random_cohort_branch'
import { TriggerHandler } from './actions/trigger.handler'
import { WaitUntilTimeWindowHandler } from './actions/wait_until_time_window'
import { ensureCurrentAction, findContinueAction, shouldSkipAction } from './hogflow-utils'
import { actionIdForLogging, ensureCurrentAction, findContinueAction, shouldSkipAction } from './hogflow-utils'
export const MAX_ACTION_STEPS_HARD_LIMIT = 1000
// Special format which the frontend understands and can render as a link
const actionIdForLogging = (action: HogFlowAction) => {
return `[Action:${action.id}]`
}
export class HogFlowExecutorService {
private readonly actionHandlers: Record<HogFlowAction['type'], ActionHandler>

View File

@@ -98,3 +98,8 @@ export async function shouldSkipAction(
return !filterResults.match
}
// Special format which the frontend understands and can render as a link
export const actionIdForLogging = (action: HogFlowAction): string => {
return `[Action:${action.id}]`
}

View File

@@ -1,5 +1,5 @@
import { EncryptedFields } from '~/cdp/encryption-utils'
import { IntegrationType } from '~/cdp/types'
import { EncryptedFields } from '~/cdp/utils/encryption-utils'
import { PubSub } from '~/utils/pubsub'
import { PostgresRouter, PostgresUse } from '../../../utils/db/postgres'

View File

@@ -25,7 +25,7 @@ describe('RecipientsManager', () => {
await closeHub(hub)
})
async function createRecipient(teamId: number, identifier: string, preferences: Record<string, string> = {}) {
const createRecipient = async (teamId: number, identifier: string, preferences: Record<string, string> = {}) => {
const id = new UUIDT().toString()
await hub.postgres.query(
PostgresUse.COMMON_WRITE,

View File

@@ -4,6 +4,7 @@ import { CyclotronJobInvocationHogFunction } from '~/cdp/types'
import { getFirstTeam, resetTestDatabase } from '~/tests/helpers/sql'
import { Hub, Team } from '~/types'
import { closeHub, createHub } from '~/utils/db/hub'
import { logger } from '~/utils/logger'
import { UUIDT } from '~/utils/utils'
import { HogFlowAction } from '../../../schema/hogflow'
@@ -23,7 +24,6 @@ describe('RecipientPreferencesService', () => {
await resetTestDatabase()
hub = await createHub()
team = await getFirstTeam(hub)
mockRecipientsManager = new RecipientsManagerService(hub)
mockRecipientsManagerGet = jest.spyOn(mockRecipientsManager, 'get')
mockRecipientsManagerGetPreference = jest.spyOn(mockRecipientsManager, 'getPreference')
@@ -82,7 +82,16 @@ describe('RecipientPreferencesService', () => {
})
.build()
return createExampleInvocation(hogFlow, { inputs: action.config.inputs })
// Hacky but we just want to test the service, so we'll add the inputs to the invocation
const inputs = Object.entries(action.config.inputs).reduce(
(acc, [key, value]) => {
acc[key] = value.value
return acc
},
{} as Record<string, any>
)
return createExampleInvocation(hogFlow, { inputs })
}
describe('shouldSkipAction', () => {
@@ -191,49 +200,23 @@ describe('RecipientPreferencesService', () => {
it('should handle errors gracefully and return false', async () => {
const action = createEmailAction('test@example.com', '123e4567-e89b-12d3-a456-426614174000')
const invocation = createFunctionStepInvocation(action)
const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
const loggerSpy = jest.spyOn(logger, 'error').mockImplementation()
mockRecipientsManagerGet.mockRejectedValue(new Error('Database error'))
const result = await service.shouldSkipAction(invocation, action)
expect(result).toBe(false)
expect(consoleSpy).toHaveBeenCalledWith(
expect(loggerSpy).toHaveBeenCalledWith(
'Failed to fetch recipient preferences for test@example.com:',
expect.any(Error)
)
consoleSpy.mockRestore()
loggerSpy.mockRestore()
})
it('should throw error if no email identifier is found', async () => {
const action: Extract<HogFlowAction, { type: 'function_email' }> = {
id: 'email',
name: 'Send email',
description: 'Send an email to the recipient',
type: 'function_email',
config: {
message_category_id: '123e4567-e89b-12d3-a456-426614174000',
template_id: 'template-email',
inputs: {
email: {
value: {
to: {
email: '',
},
from: {
email: '',
},
subject: '',
text: '',
html: '',
},
},
},
},
created_at: Date.now(),
updated_at: Date.now(),
}
const action = createEmailAction('', '123e4567-e89b-12d3-a456-426614174000')
const invocation = createFunctionStepInvocation(action)
await expect(service.shouldSkipAction(invocation, action)).rejects.toThrow(
@@ -342,7 +325,7 @@ describe('RecipientPreferencesService', () => {
message_category_id: categoryId,
inputs: {
to_number: { value: toNumber },
} as any,
},
},
created_at: Date.now(),
updated_at: Date.now(),
@@ -385,25 +368,7 @@ describe('RecipientPreferencesService', () => {
})
it('should throw error if no SMS identifier is found', async () => {
const action: Extract<HogFlowAction, { type: 'function_sms' }> = {
id: 'sms',
name: 'Send SMS',
description: 'Send an SMS to the recipient',
type: 'function_sms',
config: {
template_id: 'template-twilio',
message_category_id: '123e4567-e89b-12d3-a456-426614174000',
inputs: {
to_number: { value: '' },
message: { value: '' },
twilio_account: { value: '' },
from_number: { value: '' },
debug: { value: false },
},
},
created_at: Date.now(),
updated_at: Date.now(),
}
const action = createSmsAction('', '123e4567-e89b-12d3-a456-426614174000')
const invocation = createFunctionStepInvocation(action)
await expect(service.shouldSkipAction(invocation, action)).rejects.toThrow(
@@ -469,53 +434,6 @@ describe('RecipientPreferencesService', () => {
expect(result).toBe(false)
expect(mockRecipientsManagerGet).not.toHaveBeenCalled()
})
it('should return false for function_slack actions', async () => {
const action: Extract<HogFlowAction, { type: 'function' }> = {
id: 'slack',
name: 'Send Slack message',
description: 'Send a message to Slack',
type: 'function',
config: {
template_id: 'template-slack',
inputs: {
slack_channel: { value: 123 },
slack_workspace: { value: 123 },
},
},
created_at: Date.now(),
updated_at: Date.now(),
}
const invocation = createFunctionStepInvocation(action)
const result = await service.shouldSkipAction(invocation, action)
expect(result).toBe(false)
expect(mockRecipientsManagerGet).not.toHaveBeenCalled()
})
it('should return false for function_webhook actions', async () => {
const action: Extract<HogFlowAction, { type: 'function' }> = {
id: 'webhook',
name: 'Send webhook',
description: 'Send a webhook request',
type: 'function',
config: {
template_id: 'template-webhook',
inputs: {
url: { value: 'https://example.com/webhook' },
},
},
created_at: Date.now(),
updated_at: Date.now(),
}
const invocation = createFunctionStepInvocation(action)
const result = await service.shouldSkipAction(invocation, action)
expect(result).toBe(false)
expect(mockRecipientsManagerGet).not.toHaveBeenCalled()
})
})
})
})

View File

@@ -1,3 +1,5 @@
import { logger } from '~/utils/logger'
import { HogFlowAction } from '../../../schema/hogflow'
import { CyclotronJobInvocationHogFunction } from '../../types'
import { RecipientsManagerService } from '../managers/recipients-manager.service'
@@ -29,9 +31,9 @@ export class RecipientPreferencesService {
let identifier
if (action.type === 'function_sms') {
identifier = invocation.state.globals.inputs?.to_number?.value
identifier = invocation.state.globals.inputs?.to_number
} else if (action.type === 'function_email') {
identifier = invocation.state.globals.inputs?.email?.value?.to?.email
identifier = invocation.state.globals.inputs?.email?.to?.email
}
if (!identifier) {
@@ -67,8 +69,7 @@ export class RecipientPreferencesService {
*/
return messageCategoryPreference === 'OPTED_OUT' || allMarketingPreferences === 'OPTED_OUT'
} catch (error) {
// Log error but don't fail the execution
console.error(`Failed to fetch recipient preferences for ${identifier}:`, error)
logger.error(`Failed to fetch recipient preferences for ${identifier}:`, error)
return false
}
}

View File

@@ -0,0 +1,41 @@
import { DateTime } from 'luxon'
import { RecipientTokensService } from './recipient-tokens.service'
describe('RecipientTokensService', () => {
let service: RecipientTokensService
let fixedTime: DateTime
beforeEach(() => {
service = new RecipientTokensService({ ENCRYPTION_SALT_KEYS: 'test-secret', SITE_URL: 'https://test.com' })
fixedTime = DateTime.fromObject({ year: 2025, month: 1, day: 1 }, { zone: 'UTC' })
jest.spyOn(Date, 'now').mockReturnValue(fixedTime.toMillis())
})
it('should generate a valid token', () => {
const token = service.generatePreferencesToken({ team_id: 1, identifier: 'test@test.com' })
expect(token).toBeDefined()
expect(token).toMatchInlineSnapshot(
`"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0ZWFtX2lkIjoxLCJpZGVudGlmaWVyIjoidGVzdEB0ZXN0LmNvbSIsImlhdCI6MTczNTY4OTYwMCwiZXhwIjoxNzM2Mjk0NDAwLCJhdWQiOiJwb3N0aG9nOm1lc3NhZ2luZzpzdWJzY3JpcHRpb25fcHJlZmVyZW5jZXMifQ.qpYA4Yx5lYA2ABEd_lgjn-rSGPgl-gg4PIbH3QXIZ7g"`
)
})
it('should validate a valid token', () => {
const token = service.generatePreferencesToken({ team_id: 1, identifier: 'test@test.com' })
const result = service.validatePreferencesToken(token)
expect(result).toEqual({ valid: true, team_id: 1, identifier: 'test@test.com' })
})
it('should not accept a valid token with a different audience', () => {
const token = service['jwt'].sign({ team_id: 1, identifier: 'test@test.com' }, 'other' as any, {
expiresIn: '7d',
})
const result = service.validatePreferencesToken(token)
expect(result).toEqual({ valid: false })
})
it('should not accept an expired token', () => {
const token = service.generatePreferencesToken({ team_id: 1, identifier: 'test@test.com' })
jest.spyOn(Date, 'now').mockReturnValue(fixedTime.plus({ days: 6 }).toMillis())
const result = service.validatePreferencesToken(token)
expect(result).toEqual({ valid: true, team_id: 1, identifier: 'test@test.com' })
jest.spyOn(Date, 'now').mockReturnValue(fixedTime.plus({ days: 8 }).toMillis())
const result2 = service.validatePreferencesToken(token)
expect(result2).toEqual({ valid: false })
})
})

View File

@@ -0,0 +1,49 @@
import { Hub } from '~/types'
import { logger } from '~/utils/logger'
import { JWT, PosthogJwtAudience } from '../../utils/jwt-utils'
import { RecipientManagerRecipient } from '../managers/recipients-manager.service'
export class RecipientTokensService {
private jwt: JWT
constructor(protected hub: Pick<Hub, 'ENCRYPTION_SALT_KEYS' | 'SITE_URL'>) {
this.jwt = new JWT(hub.ENCRYPTION_SALT_KEYS ?? '')
}
public validatePreferencesToken(
token: string
): { valid: false } | { valid: true; team_id: number; identifier: string } {
try {
const decoded = this.jwt.verify(token, PosthogJwtAudience.SUBSCRIPTION_PREFERENCES, {
ignoreVerificationErrors: true,
maxAge: '7d',
})
if (!decoded) {
return { valid: false }
}
const { team_id, identifier } = decoded as { team_id: number; identifier: string }
return { valid: true, team_id, identifier }
} catch (error) {
logger.error('Error validating preferences token:', error)
return { valid: false }
}
}
public generatePreferencesToken(recipient: Pick<RecipientManagerRecipient, 'team_id' | 'identifier'>): string {
return this.jwt.sign(
{
team_id: recipient.team_id,
identifier: recipient.identifier,
},
PosthogJwtAudience.SUBSCRIPTION_PREFERENCES,
{ expiresIn: '7d' }
)
}
public generatePreferencesUrl(recipient: Pick<RecipientManagerRecipient, 'team_id' | 'identifier'>): string {
const token = this.generatePreferencesToken(recipient)
return `${this.hub.SITE_URL}/messaging-preferences/${token}/` // NOTE: Trailing slash is required for the preferences page to work
}
}

View File

@@ -40,6 +40,8 @@ export const template: HogFunctionTemplate = {
html: '<div>Hello from PostHog!</div>',
},
secret: false,
description: 'The email message to send. Configure the recipient, sender, subject, and content.',
templating: true,
},
],
}

View File

@@ -135,7 +135,7 @@ export class TemplateTester {
bytecode: [],
}
this.mockHub = {} as any
this.mockHub = { ...defaultConfig } as any
this.executor = new HogExecutorService(this.mockHub)
}
@@ -311,8 +311,8 @@ export class DestinationTester {
constructor(private template: NativeTemplate) {
this.template = template
this.executor = new NativeDestinationExecutorService({} as any)
this.inputsService = new HogInputsService({} as any)
this.executor = new NativeDestinationExecutorService(defaultConfig)
this.inputsService = new HogInputsService(defaultConfig as Hub)
this.executor.fetch = this.mockFetch

View File

@@ -100,6 +100,8 @@ export type HogFunctionInvocationGlobals = {
body: Record<string, any>
stringBody: string
}
unsubscribe_url?: string // For email actions, the unsubscribe URL to use
}
export type HogFunctionInvocationGlobalsWithInputs = HogFunctionInvocationGlobals & {

View File

@@ -1,4 +1,4 @@
import { Hub } from '../types'
import { Hub } from '../../types'
import { EncryptedFields } from './encryption-utils'
describe('Encrypted fields', () => {

View File

@@ -1,6 +1,6 @@
import { Fernet } from 'fernet-nodejs'
import { PluginsServerConfig } from '../types'
import { PluginsServerConfig } from '../../types'
export class EncryptedFields {
private fernets: Fernet[] = []

View File

@@ -0,0 +1,54 @@
import jwtLib from 'jsonwebtoken'
import { JWT, PosthogJwtAudience } from './jwt-utils'
describe('JWT', () => {
jest.setTimeout(1000)
let jwtUtil: JWT
beforeEach(() => {
jwtUtil = new JWT('testsecret1,testsecret2')
})
describe('sign and verify', () => {
it('should sign and verify a payload', () => {
const payload = { foo: 'bar', n: 42 }
const token = jwtUtil.sign(payload, PosthogJwtAudience.SUBSCRIPTION_PREFERENCES)
expect(typeof token).toBe('string')
const verified = jwtUtil.verify(token, PosthogJwtAudience.SUBSCRIPTION_PREFERENCES)
// jwt.verify returns the payload with extra fields (iat, etc)
expect((verified as any).foo).toBe('bar')
expect((verified as any).n).toBe(42)
})
it('should throw if token is invalid', () => {
expect(() => jwtUtil.verify('not.a.valid.token', PosthogJwtAudience.SUBSCRIPTION_PREFERENCES)).toThrow(
'jwt malformed'
)
})
it('should not throw if ignoreVerificationErrors is true', () => {
expect(() =>
jwtUtil.verify('not.a.valid.token', PosthogJwtAudience.SUBSCRIPTION_PREFERENCES, {
ignoreVerificationErrors: true,
})
).not.toThrow()
expect(
jwtUtil.verify('not.a.valid.token', PosthogJwtAudience.SUBSCRIPTION_PREFERENCES, {
ignoreVerificationErrors: true,
})
).toBeUndefined()
})
})
it('should throw if ENCRYPTION_SALT_KEYS is empty', () => {
const emptyEncryptionSaltKeys = ''
expect(() => new JWT(emptyEncryptionSaltKeys)).toThrow('Encryption keys are not set')
})
it('should try all secrets for verification', () => {
const payload = { foo: 'bar' }
const token = jwtLib.sign(payload, 'testsecret2', { audience: PosthogJwtAudience.SUBSCRIPTION_PREFERENCES })
expect((jwtUtil.verify(token, PosthogJwtAudience.SUBSCRIPTION_PREFERENCES) as any).foo).toBe('bar')
})
})

View File

@@ -0,0 +1,41 @@
import jwt from 'jsonwebtoken'
export enum PosthogJwtAudience {
SUBSCRIPTION_PREFERENCES = 'posthog:messaging:subscription_preferences',
}
export class JWT {
private secrets: string[] = []
constructor(commaSeparatedSaltKeys: string) {
const saltKeys = commaSeparatedSaltKeys.split(',').filter((key) => key)
if (!saltKeys.length) {
throw new Error('Encryption keys are not set')
}
this.secrets = saltKeys
}
sign(payload: object, audience: PosthogJwtAudience, options?: jwt.SignOptions): string {
return jwt.sign(payload, this.secrets[0], { ...options, audience: audience })
}
verify(
token: string,
audience: PosthogJwtAudience,
options?: jwt.VerifyOptions & { ignoreVerificationErrors?: boolean }
): string | jwt.Jwt | jwt.JwtPayload | undefined {
let error: Error | undefined
for (const secret of this.secrets) {
try {
const payload = jwt.verify(token, secret, { ...options, audience: audience })
return payload
} catch (e) {
error = e
}
}
if (options?.ignoreVerificationErrors) {
return undefined
}
throw error
}
}

View File

@@ -18,12 +18,7 @@ export class LiquidRenderer {
static renderWithHogFunctionGlobals(template: string, globals: HogFunctionInvocationGlobalsWithInputs): string {
const context = {
event: globals.event,
person: globals.person,
groups: globals.groups,
project: globals.project,
source: globals.source,
inputs: globals.inputs || {},
...globals,
now: new Date(),
}

View File

@@ -106,7 +106,7 @@ const HogFlowActionSchema = z.discriminatedUnion('type', [
config: z.object({
template_uuid: z.string().uuid().optional(), // May be used later to specify a specific template version
template_id: z.string(),
inputs: z.object({}),
inputs: z.record(CyclotronInputSchema),
}),
}),
z.object({

View File

@@ -20,9 +20,9 @@ import {
import { QuotaLimiting } from '~/common/services/quota-limiting.service'
import { EncryptedFields } from './cdp/encryption-utils'
import { IntegrationManagerService } from './cdp/services/managers/integration-manager.service'
import { CyclotronJobQueueKind, CyclotronJobQueueSource } from './cdp/types'
import { EncryptedFields } from './cdp/utils/encryption-utils'
import { InternalCaptureService } from './common/services/internal-capture'
import type { CookielessManager } from './ingestion/cookieless/cookieless-manager'
import { KafkaProducerWrapper } from './kafka/producer'

View File

@@ -9,7 +9,7 @@ import { InternalCaptureService } from '~/common/services/internal-capture'
import { QuotaLimiting } from '~/common/services/quota-limiting.service'
import { getPluginServerCapabilities } from '../../capabilities'
import { EncryptedFields } from '../../cdp/encryption-utils'
import { EncryptedFields } from '../../cdp/utils/encryption-utils'
import { buildIntegerMatcher, defaultConfig } from '../../config/config'
import { KAFKAJS_LOG_LEVEL_MAPPING } from '../../config/constants'
import { CookielessManager } from '../../ingestion/cookieless/cookieless-manager'

View File

@@ -0,0 +1,7 @@
import { DateTime } from 'luxon'
// Helper for mocking the date in tests - works whether local or on CI
const fixedTime = DateTime.fromObject({ year: 2025, month: 1, day: 1 }, { zone: 'UTC' })
jest.spyOn(Date, 'now').mockReturnValue(fixedTime.toMillis())
export const mockNow = Date.now

107
pnpm-lock.yaml generated
View File

@@ -1298,6 +1298,9 @@ importers:
js-big-decimal:
specifier: ^2.2.0
version: 2.2.0
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
kafkajs:
specifier: ^2.2.0
version: 2.2.4
@@ -1425,6 +1428,9 @@ importers:
'@types/jest':
specifier: ^30.0.0
version: 30.0.0
'@types/jsonwebtoken':
specifier: ^9.0.10
version: 9.0.10
'@types/kafkajs-snappy':
specifier: ^1.0.0
version: 1.0.0
@@ -8487,6 +8493,9 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/kafkajs-snappy@1.0.0':
resolution: {integrity: sha512-P0DdLdmn5z4foYpCVTMpEGVpfrHG7C6cpzeysxw79rfyESp5/k0wiCOfI4euDKsc6N/2+BHaNoTGwmNLmxCUWw==}
@@ -12959,13 +12968,23 @@ packages:
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
jsonwebtoken@9.0.2:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
engines: {node: '>=12', npm: '>=6'}
jsprim@2.0.2:
resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==}
engines: {'0': node >=0.6.0}
jwa@1.4.2:
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
jwa@2.0.0:
resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==}
jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
jws@4.0.0:
resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==}
@@ -13286,19 +13305,40 @@ packages:
lodash.flattendeep@4.4.0:
resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash.snakecase@4.1.1:
resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
@@ -23054,7 +23094,7 @@ snapshots:
browserslist: 4.24.3
json5: 2.2.3
nullthrows: 1.1.1
semver: 7.7.0
semver: 7.7.2
transitivePeerDependencies:
- '@parcel/core'
@@ -23079,7 +23119,7 @@ snapshots:
posthtml: 0.16.6
posthtml-parser: 0.12.1
posthtml-render: 3.0.0
semver: 7.7.0
semver: 7.7.2
srcset: 4.0.0
transitivePeerDependencies:
- '@parcel/core'
@@ -23123,7 +23163,7 @@ snapshots:
clone: 2.1.2
nullthrows: 1.1.1
postcss-value-parser: 4.2.0
semver: 7.7.0
semver: 7.7.2
transitivePeerDependencies:
- '@parcel/core'
@@ -23135,7 +23175,7 @@ snapshots:
posthtml: 0.16.6
posthtml-parser: 0.12.1
posthtml-render: 3.0.0
semver: 7.7.0
semver: 7.7.2
transitivePeerDependencies:
- '@parcel/core'
@@ -23162,7 +23202,7 @@ snapshots:
posthtml: 0.16.6
posthtml-parser: 0.12.1
posthtml-render: 3.0.0
semver: 7.7.0
semver: 7.7.2
transitivePeerDependencies:
- '@parcel/core'
@@ -25480,7 +25520,7 @@ snapshots:
magic-string: 0.30.5
path-browserify: 1.0.1
process: 0.11.10
semver: 7.7.0
semver: 7.7.2
style-loader: 3.3.3(webpack@5.88.2)
swc-loader: 0.2.3(@swc/core@1.11.4(@swc/helpers@0.5.15))(webpack@5.88.2)
terser-webpack-plugin: 5.3.9(@swc/core@1.11.4(@swc/helpers@0.5.15))(esbuild@0.18.20)(webpack@5.88.2)
@@ -25557,7 +25597,7 @@ snapshots:
prompts: 2.4.2
puppeteer-core: 2.1.1
read-pkg-up: 7.0.1
semver: 7.7.0
semver: 7.7.2
simple-update-notifier: 2.0.0
strip-json-comments: 3.1.1
tempy: 1.0.1
@@ -25791,7 +25831,7 @@ snapshots:
dequal: 2.0.3
lodash: 4.17.21
memoizerific: 1.11.3
semver: 7.7.0
semver: 7.7.2
store2: 2.14.4
telejson: 7.2.0
ts-dedent: 2.2.0
@@ -25826,7 +25866,7 @@ snapshots:
react-docgen: 7.0.1
react-dom: 18.2.0(react@18.2.0)
react-refresh: 0.14.0
semver: 7.7.0
semver: 7.7.2
webpack: 5.88.2(@swc/core@1.11.4(@swc/helpers@0.5.15))(esbuild@0.18.20)(webpack-cli@5.1.4)
optionalDependencies:
'@babel/core': 7.26.0
@@ -26953,6 +26993,11 @@ snapshots:
'@types/json5@0.0.29': {}
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 0.7.31
'@types/node': 22.15.17
'@types/kafkajs-snappy@1.0.0':
dependencies:
'@types/node': 22.15.17
@@ -27324,7 +27369,7 @@ snapshots:
globby: 11.1.0
is-glob: 4.0.3
minimatch: 9.0.3
semver: 7.7.0
semver: 7.7.2
ts-api-utils: 1.3.0(typescript@5.2.2)
optionalDependencies:
typescript: 5.2.2
@@ -32599,6 +32644,19 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
jsonwebtoken@9.0.2:
dependencies:
jws: 3.2.2
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.7.2
jsprim@2.0.2:
dependencies:
assert-plus: 1.0.0
@@ -32606,12 +32664,23 @@ snapshots:
json-schema: 0.4.0
verror: 1.10.0
jwa@1.4.2:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jwa@2.0.0:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@3.2.2:
dependencies:
jwa: 1.4.2
safe-buffer: 5.2.1
jws@4.0.0:
dependencies:
jwa: 2.0.0
@@ -32927,14 +32996,28 @@ snapshots:
lodash.flattendeep@4.4.0: {}
lodash.includes@4.3.0: {}
lodash.isarguments@3.1.0: {}
lodash.isboolean@3.0.3: {}
lodash.isequal@4.5.0: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.memoize@4.1.2: {}
lodash.merge@4.6.2: {}
lodash.once@4.1.1: {}
lodash.snakecase@4.1.1: {}
lodash.sortby@4.7.0: {}
@@ -33605,7 +33688,7 @@ snapshots:
make-fetch-happen: 13.0.1
nopt: 7.2.1
proc-log: 4.2.0
semver: 7.7.0
semver: 7.7.2
tar: 6.2.1
which: 4.0.0
transitivePeerDependencies:
@@ -36567,7 +36650,7 @@ snapshots:
simple-update-notifier@2.0.0:
dependencies:
semver: 7.7.0
semver: 7.7.2
sisteransi@1.0.5: {}

View File

@@ -1,7 +1,5 @@
import uuid
from typing import Optional
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from django.db import models
from posthog.models.utils import UUIDTModel
@@ -33,33 +31,6 @@ class MessageRecipientPreference(UUIDTModel):
def __str__(self) -> str:
return f"Preferences for {self.identifier}"
def generate_preferences_token(self) -> str:
"""Generate a secure, time-limited token for accessing preferences"""
signer = TimestampSigner()
return signer.sign_object({"id": str(self.id), "identifier": self.identifier})
@classmethod
def validate_preferences_token(
cls, token: str, max_age: int = 60 * 60 * 24 * 7
) -> tuple[Optional["MessageRecipientPreference"], str]:
"""
Validate a preferences token and return the recipient if valid
max_age defaults to 7 days
Returns (recipient, error_message). If validation fails, recipient will be None
"""
signer = TimestampSigner()
try:
data = signer.unsign_object(token, max_age=max_age)
return cls.objects.get(id=uuid.UUID(data["id"]), identifier=data["identifier"]), ""
except SignatureExpired:
return None, "This link has expired. Please request a new one."
except BadSignature:
return None, "Invalid or tampered preferences link."
except cls.DoesNotExist:
return None, "Recipient not found."
except Exception:
return None, "An error occurred validating your preferences link."
def set_preference(self, category_id: uuid.UUID, status: PreferenceStatus) -> None:
"""Set preference for a specific category"""
if not isinstance(status, PreferenceStatus):
@@ -73,21 +44,6 @@ class MessageRecipientPreference(UUIDTModel):
status = self.preferences.get(str(category_id), PreferenceStatus.NO_PREFERENCE.value)
return PreferenceStatus(status)
def get_all_preferences(self) -> dict[uuid.UUID, PreferenceStatus]:
"""Get all preferences as a dictionary of UUID to PreferenceStatus"""
return {uuid.UUID(category_id): PreferenceStatus(status) for category_id, status in self.preferences.items()}
@classmethod
def get_or_create_for_identifier(
cls, team_id: int, identifier: str, defaults: Optional[dict[uuid.UUID, PreferenceStatus]] = None
) -> "MessageRecipientPreference":
"""Get or create preferences for an identifier"""
if defaults is None:
defaults = {}
preferences_dict = {str(category_id): status.value for category_id, status in defaults.items()}
instance, _ = cls.objects.get_or_create(
team_id=team_id, identifier=identifier, defaults={"preferences": preferences_dict}
)
return instance
def get_all_preferences(self) -> dict[str, PreferenceStatus]:
"""Get all preferences as a dictionary of category ID to PreferenceStatus"""
return {str(category_id): PreferenceStatus(status) for category_id, status in self.preferences.items()}

View File

@@ -1,10 +1,12 @@
import uuid
from posthog.test.base import BaseTest
from unittest.mock import patch
from django.db import IntegrityError
from django.test import Client
import posthog.plugins.plugin_server_api as plugin_server_api
from posthog.models.message_category import MessageCategory
from posthog.models.message_preferences import MessageRecipientPreference, PreferenceStatus
@@ -26,7 +28,15 @@ class TestMessagePreferences(BaseTest):
team=self.team, identifier="test@example.com", preferences={}
)
self.client = Client()
self.token = self.recipient.generate_preferences_token()
self._token_patch = patch.object(
plugin_server_api, "generate_messaging_preferences_token", return_value="dummy-token"
)
self._token_patch.start()
self.token = plugin_server_api.generate_messaging_preferences_token(self.team.id, self.recipient.identifier)
def tearDown(self):
self._token_patch.stop()
super().tearDown()
def test_create_message_category(self):
category = MessageCategory.objects.create(
@@ -88,34 +98,13 @@ class TestMessagePreferences(BaseTest):
# Test get_all_preferences method (returns dict of UUID to PreferenceStatus)
preferences = recipient.get_all_preferences()
self.assertEqual(preferences[self.category.id], PreferenceStatus.OPTED_IN)
self.assertEqual(preferences[category2.id], PreferenceStatus.OPTED_OUT)
self.assertEqual(preferences[str(self.category.id)], PreferenceStatus.OPTED_IN)
self.assertEqual(preferences[str(category2.id)], PreferenceStatus.OPTED_OUT)
# Test get_all_preference method (also returns dict of UUID to PreferenceStatus)
all_preferences = recipient.get_all_preferences()
self.assertEqual(all_preferences[self.category.id], PreferenceStatus.OPTED_IN)
self.assertEqual(all_preferences[category2.id], PreferenceStatus.OPTED_OUT)
def test_get_or_create_for_identifier(self):
# Test creating a new recipient
defaults = {self.category.id: PreferenceStatus.OPTED_IN}
recipient = MessageRecipientPreference.get_or_create_for_identifier(
team_id=self.team.id, identifier="new@example.com", defaults=defaults
)
self.assertEqual(recipient.identifier, "new@example.com")
self.assertEqual(recipient.team_id, self.team.id)
# Check that the preference was set correctly
self.assertEqual(recipient.get_preference(self.category.id), PreferenceStatus.OPTED_IN)
# Test getting an existing recipient
existing_recipient = MessageRecipientPreference.get_or_create_for_identifier(
team_id=self.team.id, identifier="new@example.com"
)
# Should be the same instance
self.assertEqual(existing_recipient.id, recipient.id)
self.assertEqual(existing_recipient.get_preference(self.category.id), PreferenceStatus.OPTED_IN)
self.assertEqual(all_preferences[str(self.category.id)], PreferenceStatus.OPTED_IN)
self.assertEqual(all_preferences[str(category2.id)], PreferenceStatus.OPTED_OUT)
def test_set_preference_validation(self):
recipient = MessageRecipientPreference.objects.create(
@@ -125,25 +114,3 @@ class TestMessagePreferences(BaseTest):
# Test that only PreferenceStatus enum values are accepted
with self.assertRaises(ValueError):
recipient.set_preference(self.category.id, "INVALID_STATUS") # type: ignore[arg-type]
def test_token_generation_and_validation(self):
recipient = MessageRecipientPreference.objects.create(
team=self.team, identifier="test7@example.com", preferences={}
)
# Test token generation
token = recipient.generate_preferences_token()
self.assertIsNotNone(token)
# Test token validation
validated_recipient, error = MessageRecipientPreference.validate_preferences_token(token)
self.assertIsNotNone(validated_recipient, "Validated recipient should not be None")
self.assertEqual(error, "")
# Only check ID if we have a recipient
if validated_recipient: # This satisfies the type checker
self.assertEqual(validated_recipient.id, recipient.id)
# Test invalid token
invalid_recipient, error = MessageRecipientPreference.validate_preferences_token("invalid-token")
self.assertIsNone(invalid_recipient)
self.assertNotEqual(error, "")

View File

@@ -85,6 +85,18 @@ def patch_hog_function_status(team_id: int, hog_function_id: UUIDT, state: int)
)
def generate_messaging_preferences_token(team_id: int, identifier: str) -> str:
payload = {"team_id": team_id, "identifier": identifier}
response = requests.post(CDP_API_URL + "/api/messaging/generate_preferences_token", json=payload)
if response.status_code == 200:
return response.json().get("token")
return ""
def validate_messaging_preferences_token(token: str) -> requests.Response:
return requests.get(CDP_API_URL + f"/api/messaging/validate_preferences_token/{token}")
def get_hog_function_templates() -> requests.Response:
return requests.get(CDP_API_URL + f"/api/hog_function_templates")

View File

@@ -33,6 +33,7 @@ from posthog.models.message_preferences import (
PreferenceStatus,
)
from posthog.models.personal_api_key import find_personal_api_key
from posthog.plugins.plugin_server_api import validate_messaging_preferences_token
from posthog.redis import get_client
from posthog.utils import (
get_available_timezones_with_offsets,
@@ -308,18 +309,28 @@ def api_key_search_view(request: HttpRequest):
@require_http_methods(["GET"])
def preferences_page(request: HttpRequest, token: str) -> HttpResponse:
"""Render the preferences page for a given recipient token"""
recipient, error = MessageRecipientPreference.validate_preferences_token(token)
response = validate_messaging_preferences_token(token)
if response.status_code != 200:
error_msg = response.json().get("error", "Invalid recipient token")
return render(request, "message_preferences/error.html", {"error": error_msg}, status=400)
if error:
return render(request, "message_preferences/error.html", {"error": error}, status=400)
data = response.json()
if not data.get("valid"):
return render(request, "message_preferences/error.html", {"error": "Invalid recipient token"}, status=400)
if not recipient:
team_id = data.get("team_id")
identifier = data.get("identifier")
if not team_id or not identifier:
return render(request, "message_preferences/error.html", {"error": "Invalid recipient"}, status=400)
try:
recipient = MessageRecipientPreference.objects.get(team_id=team_id, identifier=identifier)
except MessageRecipientPreference.DoesNotExist:
# A first-time preferences page visitor will not have a recipient in Postgres yet.
recipient = None
# Only fetch active categories and their preferences
categories = MessageCategory.objects.filter(deleted=False, team=recipient.team, category_type="marketing").order_by(
"name"
)
categories = MessageCategory.objects.filter(deleted=False, team=team_id, category_type="marketing").order_by("name")
preferences = recipient.get_all_preferences() if recipient else {}
context = {
@@ -329,11 +340,11 @@ def preferences_page(request: HttpRequest, token: str) -> HttpResponse:
"id": cat.id,
"name": cat.name,
"description": cat.public_description,
"status": preferences.get(cat.id),
"status": preferences.get(str(cat.id), PreferenceStatus.NO_PREFERENCE),
}
for cat in categories
],
"token": token, # Need to pass this back for the update endpoint
"token": token,
}
return render(request, "message_preferences/preferences.html", context)
@@ -347,13 +358,26 @@ def update_preferences(request: HttpRequest) -> JsonResponse:
if not token:
return JsonResponse({"error": "Missing token"}, status=400)
recipient, error = MessageRecipientPreference.validate_preferences_token(token)
if error:
return JsonResponse({"error": error}, status=400)
response = validate_messaging_preferences_token(token)
if response.status_code != 200:
error_msg = response.json().get("error", "Invalid recipient token")
return JsonResponse({"error": error_msg}, status=400)
if not recipient:
data = response.json()
if not data.get("valid"):
return JsonResponse({"error": "Invalid recipient token"}, status=400)
team_id = data.get("team_id")
identifier = data.get("identifier")
if not team_id or not identifier:
return JsonResponse({"error": "Invalid recipient"}, status=400)
recipient = None
try:
recipient = MessageRecipientPreference.objects.get(team_id=team_id, identifier=identifier)
except MessageRecipientPreference.DoesNotExist:
recipient = MessageRecipientPreference(team_id=team_id, identifier=identifier)
try:
preferences = request.POST.getlist("preferences[]")
# Convert to dict of category_id: status
@@ -378,9 +402,10 @@ def update_preferences(request: HttpRequest) -> JsonResponse:
# Update all preferences with a single DB write
recipient.preferences = preferences_dict
recipient.save(update_fields=["preferences", "updated_at"])
recipient.save()
return JsonResponse({"success": True})
except Exception:
except Exception as e:
capture_exception(e)
return JsonResponse({"error": "Failed to update preferences"}, status=400)

View File

@@ -5,6 +5,7 @@ from rest_framework.response import Response
from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.models import MessageCategory, MessageRecipientPreference
from posthog.models.message_preferences import ALL_MESSAGE_PREFERENCE_CATEGORY_ID, PreferenceStatus
from posthog.plugins import plugin_server_api
class MessagePreferencesSerializer(serializers.ModelSerializer):
@@ -67,11 +68,7 @@ class MessagePreferencesViewSet(TeamAndOrgViewSetMixin, viewsets.ViewSet):
identifier = request.data.get("recipient", user.email)
# Get or create preferences for the user's email
recipient = MessageRecipientPreference.get_or_create_for_identifier(team_id=self.team_id, identifier=identifier)
# Generate the preferences token
token = recipient.generate_preferences_token()
token = plugin_server_api.generate_messaging_preferences_token(self.team_id, identifier)
# Build the full URL
preferences_url = f"{request.build_absolute_uri('/')[:-1]}/messaging-preferences/{token}/"

View File

@@ -1,10 +1,14 @@
import json
from posthog.test.base import APIBaseTest, BaseTest
from unittest.mock import patch
from django.test import Client
from django.urls import reverse
from requests import Response
import posthog.plugins.plugin_server_api as plugin_server_api
from posthog.models.message_category import MessageCategory
from posthog.models.message_preferences import (
ALL_MESSAGE_PREFERENCE_CATEGORY_ID,
@@ -13,6 +17,13 @@ from posthog.models.message_preferences import (
)
def mock_response(status_code: int, response_json: dict):
response = Response()
response.status_code = status_code
response.json = lambda: response_json # type: ignore
return response
class TestMessagePreferencesViews(BaseTest):
def setUp(self):
super().setUp()
@@ -30,10 +41,23 @@ class TestMessagePreferencesViews(BaseTest):
team=self.team, identifier="test@example.com", preferences={}
)
self.client = Client()
self.token = self.recipient.generate_preferences_token()
self._token_patch = patch.object(
plugin_server_api, "generate_messaging_preferences_token", return_value="dummy-token"
)
self._token_patch.start()
self.token = plugin_server_api.generate_messaging_preferences_token(self.team.id, self.recipient.identifier)
def test_preferences_page_valid_token(self):
def tearDown(self):
self._token_patch.stop()
super().tearDown()
@patch("posthog.views.validate_messaging_preferences_token")
def test_preferences_page_valid_token(self, mock_validate_messaging_preferences_token):
mock_validate_messaging_preferences_token.return_value = mock_response(
200, {"valid": True, "team_id": self.team.id, "identifier": self.recipient.identifier}
)
response = self.client.get(reverse("message_preferences", kwargs={"token": self.token}))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "message_preferences/preferences.html")
@@ -47,13 +71,19 @@ class TestMessagePreferencesViews(BaseTest):
self.assertEqual(categories[0]["name"], "Newsletter Updates")
self.assertEqual(categories[1]["name"], "Product Updates")
def test_preferences_page_invalid_token(self):
@patch("posthog.views.validate_messaging_preferences_token")
def test_preferences_page_invalid_token(self, mock_validate_messaging_preferences_token):
mock_validate_messaging_preferences_token.return_value = mock_response(400, {"error": "Invalid token"})
response = self.client.get(reverse("message_preferences", kwargs={"token": "invalid-token"}))
self.assertEqual(response.status_code, 400)
self.assertTemplateUsed(response, "message_preferences/error.html")
def test_update_preferences_valid(self):
@patch("posthog.views.validate_messaging_preferences_token")
def test_update_preferences_valid(self, mock_validate_messaging_preferences_token):
data = {"token": self.token, "preferences[]": [f"{self.category.id}:true", f"{self.category2.id}:false"]}
mock_validate_messaging_preferences_token.return_value = mock_response(
200, {"valid": True, "team_id": self.team.id, "identifier": self.recipient.identifier}
)
response = self.client.post(reverse("message_preferences_update"), data)
self.assertEqual(response.status_code, 200)
self.assertEqual(json.loads(response.content), {"success": True})
@@ -61,8 +91,8 @@ class TestMessagePreferencesViews(BaseTest):
# Verify preferences were updated
self.recipient.refresh_from_db()
prefs = self.recipient.get_all_preferences()
self.assertEqual(prefs[self.category.id], PreferenceStatus.OPTED_IN)
self.assertEqual(prefs[self.category2.id], PreferenceStatus.OPTED_OUT)
self.assertEqual(prefs[str(self.category.id)], PreferenceStatus.OPTED_IN)
self.assertEqual(prefs[str(self.category2.id)], PreferenceStatus.OPTED_OUT)
def test_update_preferences_missing_token(self):
response = self.client.post(
@@ -72,14 +102,20 @@ class TestMessagePreferencesViews(BaseTest):
self.assertEqual(response.status_code, 400)
self.assertEqual(json.loads(response.content), {"error": "Missing token"})
def test_update_preferences_invalid_token(self):
@patch("posthog.views.validate_messaging_preferences_token")
def test_update_preferences_invalid_token(self, mock_validate_messaging_preferences_token):
data = {"token": "invalid-token", "preferences[]": [f"{self.category.id}:true"]}
mock_validate_messaging_preferences_token.return_value = mock_response(400, {"error": "Invalid token"})
response = self.client.post(reverse("message_preferences_update"), data)
self.assertEqual(response.status_code, 400)
self.assertIn("error", json.loads(response.content))
def test_update_preferences_invalid_preference_format(self):
@patch("posthog.views.validate_messaging_preferences_token")
def test_update_preferences_invalid_preference_format(self, mock_validate_messaging_preferences_token):
data = {"token": self.token, "preferences[]": ["invalid:format"]}
mock_validate_messaging_preferences_token.return_value = mock_response(
200, {"valid": True, "team_id": self.team.id, "identifier": self.recipient.identifier}
)
response = self.client.post(reverse("message_preferences_update"), data)
self.assertEqual(response.status_code, 400)
self.assertEqual(json.loads(response.content), {"error": "Preference values must be 'true' or 'false'"})

View File

@@ -29,7 +29,7 @@ export function CampaignScene(props: CampaignSceneLogicProps): JSX.Element {
const logic = campaignLogic(props)
const { campaignLoading, campaign, originalCampaign } = useValues(logic)
if (campaignLoading) {
if (!originalCampaign && campaignLoading) {
return <SpinnerOverlay sceneLevel />
}

View File

@@ -222,16 +222,18 @@ export const campaignLogic = kea<campaignLogicType>([
},
],
}),
listeners(({ actions, values }) => ({
listeners(({ actions, values, props }) => ({
loadCampaignSuccess: async ({ originalCampaign }) => {
actions.resetCampaign(originalCampaign)
},
saveCampaignSuccess: async ({ originalCampaign }) => {
lemonToast.success('Campaign saved')
originalCampaign.id &&
if (props.id === 'new' && originalCampaign.id) {
router.actions.replace(
urls.messagingCampaign(originalCampaign.id, campaignSceneLogic.findMounted()?.values.currentTab)
)
}
actions.resetCampaign(originalCampaign)
},
discardChanges: () => {

View File

@@ -15,7 +15,15 @@ export const ACTION_NODES_TO_SHOW: CreateActionType[] = [
type: 'function_email',
name: 'Email',
description: 'Send an email to the user.',
config: { template_id: 'template-email', inputs: {} },
config: {
template_id: 'template-email',
inputs: {
email: {
// Default email inputs to liquid templating
templating: 'liquid',
},
},
},
},
{
type: 'function_sms',

View File

@@ -52,7 +52,7 @@ export function HogFlowEditorPanelBuildDetail(): JSX.Element | null {
{isOptOutEligibleAction(action) && (
<>
<LemonDivider className="my-0" />
<div className="flex flex-col p-2">
<div className="flex flex-col px-2 py-1">
<LemonLabel htmlFor="Message category" className="flex gap-2 justify-between items-center">
<span>Message category</span>
<div className="flex gap-2">

View File

@@ -15,6 +15,8 @@ export const CategorySelect = ({
return (
<LemonSelect
size="small"
type="tertiary"
onChange={onChange}
value={value}
loading={categoriesLoading}