mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
feat(messaging): Unsubscribe links (#37679)
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 245 KiB |
@@ -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 }) => (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}]`
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Hub } from '../types'
|
||||
import { Hub } from '../../types'
|
||||
import { EncryptedFields } from './encryption-utils'
|
||||
|
||||
describe('Encrypted fields', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Fernet } from 'fernet-nodejs'
|
||||
|
||||
import { PluginsServerConfig } from '../types'
|
||||
import { PluginsServerConfig } from '../../types'
|
||||
|
||||
export class EncryptedFields {
|
||||
private fernets: Fernet[] = []
|
||||
54
plugin-server/src/cdp/utils/jwt-utils.test.ts
Normal file
54
plugin-server/src/cdp/utils/jwt-utils.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
41
plugin-server/src/cdp/utils/jwt-utils.ts
Normal file
41
plugin-server/src/cdp/utils/jwt-utils.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
7
plugin-server/tests/helpers/mocks/date.mock.ts
Normal file
7
plugin-server/tests/helpers/mocks/date.mock.ts
Normal 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
107
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}/"
|
||||
|
||||
@@ -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'"})
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -15,6 +15,8 @@ export const CategorySelect = ({
|
||||
|
||||
return (
|
||||
<LemonSelect
|
||||
size="small"
|
||||
type="tertiary"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
loading={categoriesLoading}
|
||||
|
||||
Reference in New Issue
Block a user