From fa9b9401d367b3d854608f524d7a19cb6682506e Mon Sep 17 00:00:00 2001 From: Haven Date: Mon, 10 Nov 2025 10:43:59 -0600 Subject: [PATCH] chore(workflows): remove Mailjet as email provider for Workflows (#41141) Co-authored-by: github-actions[bot] --- ee/settings.py | 3 - mypy-baseline.txt | 2 - plugin-server/src/cdp/cdp-api.ts | 12 - .../messaging/email-tracking.service.test.ts | 117 +------- .../messaging/email-tracking.service.ts | 53 +--- .../services/messaging/email.service.test.ts | 55 ++-- .../cdp/services/messaging/email.service.ts | 49 +--- .../src/cdp/services/messaging/types.ts | 186 ------------- plugin-server/src/config/config.ts | 6 +- plugin-server/src/types.ts | 6 +- posthog/api/integration.py | 2 +- posthog/api/test/test_integration.py | 108 ++++---- posthog/models/integration.py | 42 +-- posthog/models/test/test_integration_model.py | 10 +- posthog/settings/__init__.py | 1 - posthog/settings/workflows.py | 4 - .../workflows/backend/providers/__init__.py | 3 +- .../workflows/backend/providers/mailjet.py | 211 --------------- .../backend/test/test_mailjet_provider.py | 254 ------------------ .../Channels/EmailSetup/EmailSetupModal.tsx | 1 - .../EmailSetup/emailSetupModalLogic.ts | 2 +- 21 files changed, 93 insertions(+), 1034 deletions(-) delete mode 100644 plugin-server/src/cdp/services/messaging/types.ts delete mode 100644 posthog/settings/workflows.py delete mode 100644 products/workflows/backend/providers/mailjet.py delete mode 100644 products/workflows/backend/test/test_mailjet_provider.py diff --git a/ee/settings.py b/ee/settings.py index ff1e71d605..53363dda4e 100644 --- a/ee/settings.py +++ b/ee/settings.py @@ -94,9 +94,6 @@ AZURE_INFERENCE_ENDPOINT = get_from_env("AZURE_INFERENCE_ENDPOINT", "") AZURE_INFERENCE_CREDENTIAL = get_from_env("AZURE_INFERENCE_CREDENTIAL", "") BRAINTRUST_API_KEY = get_from_env("BRAINTRUST_API_KEY", "") -MAILJET_PUBLIC_KEY = get_from_env("MAILJET_PUBLIC_KEY", "", type_cast=str) -MAILJET_SECRET_KEY = get_from_env("MAILJET_SECRET_KEY", "", type_cast=str) - SQS_QUEUES = { "usage_reports": { "url": get_from_env("SQS_USAGE_REPORT_QUEUE_URL", optional=True), diff --git a/mypy-baseline.txt b/mypy-baseline.txt index aa3d2d733c..5dec5cbf9c 100644 --- a/mypy-baseline.txt +++ b/mypy-baseline.txt @@ -768,8 +768,6 @@ posthog/queries/trends/util.py:0: error: Argument 1 to "translate_hogql" has inc posthog/rbac/test/test_field_access_control.py:0: error: Team has no field named 'session_recording_opt_in' [misc] posthog/rbac/test/test_field_access_control.py:0: error: TestModel has no field named 'test_field' [misc] posthog/schema_migrations/upgrade_manager.py:0: error: Argument 1 to "upgrade" has incompatible type "Any | None"; expected "dict[Any, Any]" [arg-type] -posthog/settings/__init__.py:0: error: Cannot determine type of "MAILJET_PUBLIC_KEY" [has-type] -posthog/settings/__init__.py:0: error: Cannot determine type of "MAILJET_SECRET_KEY" [has-type] posthog/settings/__init__.py:0: error: Cannot determine type of "SOCIAL_AUTH_GOOGLE_OAUTH2_KEY" [has-type] posthog/settings/__init__.py:0: error: Cannot determine type of "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET" [has-type] posthog/settings/data_stores.py:0: error: Name "DATABASE_URL" already defined on line 0 [no-redef] diff --git a/plugin-server/src/cdp/cdp-api.ts b/plugin-server/src/cdp/cdp-api.ts index d874384942..ccc6dc8820 100644 --- a/plugin-server/src/cdp/cdp-api.ts +++ b/plugin-server/src/cdp/cdp-api.ts @@ -125,7 +125,6 @@ export class CdpApi { router.post('/public/webhooks/:webhook_id', asyncHandler(this.handleWebhook())) router.get('/public/webhooks/:webhook_id', asyncHandler(this.handleWebhook())) router.get('/public/m/pixel', asyncHandler(this.getEmailTrackingPixel())) - router.post('/public/m/mailjet_webhook', asyncHandler(this.postMailjetWebhook())) router.post('/public/m/ses_webhook', express.text(), asyncHandler(this.postSesWebhook())) router.get('/public/m/redirect', asyncHandler(this.getEmailTrackingRedirect())) @@ -510,17 +509,6 @@ export class CdpApi { } } - private postMailjetWebhook = - () => - async (req: ModifiedRequest, res: express.Response): Promise => { - try { - const { status, message } = await this.emailTrackingService.handleMailjetWebhook(req) - return res.status(status).json({ message }) - } catch (error) { - return res.status(500).json({ error: 'Internal error' }) - } - } - private postSesWebhook = () => async (req: ModifiedRequest, res: express.Response): Promise => { diff --git a/plugin-server/src/cdp/services/messaging/email-tracking.service.test.ts b/plugin-server/src/cdp/services/messaging/email-tracking.service.test.ts index 5490faef8b..f39de1197e 100644 --- a/plugin-server/src/cdp/services/messaging/email-tracking.service.test.ts +++ b/plugin-server/src/cdp/services/messaging/email-tracking.service.test.ts @@ -6,21 +6,16 @@ import supertest from 'supertest' import express from 'ultimate-express' import { setupExpressApp } from '~/api/router' -import { FixtureHogFlowBuilder } from '~/cdp/_tests/builders/hogflow.builder' import { insertHogFunction } from '~/cdp/_tests/fixtures' -import { insertHogFlow } from '~/cdp/_tests/fixtures-hogflows' import { CdpApi } from '~/cdp/cdp-api' import { HogFunctionType } from '~/cdp/types' import { KAFKA_APP_METRICS_2 } from '~/config/kafka-topics' -import { HogFlow } from '~/schema/hogflow' import { getFirstTeam, resetTestDatabase } from '~/tests/helpers/sql' import { closeHub, createHub } from '~/utils/db/hub' import { UUIDT } from '~/utils/utils' import { Hub, Team } from '../../../types' import { PIXEL_GIF } from './email-tracking.service' -import { generateEmailTrackingCode } from './helpers/tracking-code' -import { MailjetEventBase, MailjetWebhookEvent } from './types' describe('EmailTrackingService', () => { let hub: Hub @@ -28,10 +23,7 @@ describe('EmailTrackingService', () => { beforeEach(async () => { await resetTestDatabase() - hub = await createHub({ - MAILJET_SECRET_KEY: 'mailjet-secret-key', - MAILJET_PUBLIC_KEY: 'mailjet-public-key', - }) + hub = await createHub({}) team = await getFirstTeam(hub) mockFetch.mockClear() @@ -46,10 +38,8 @@ describe('EmailTrackingService', () => { let api: CdpApi let app: express.Application let hogFunction: HogFunctionType - let hogFlow: HogFlow const invocationId = 'invocation-id' let server: Server - let exampleEvent: MailjetWebhookEvent beforeEach(async () => { api = new CdpApi(hub) @@ -58,117 +48,12 @@ describe('EmailTrackingService', () => { server = app.listen(0, () => {}) hogFunction = await insertHogFunction(hub.postgres, team.id) - hogFlow = await insertHogFlow(hub.postgres, new FixtureHogFlowBuilder().withTeamId(team.id).build()) - exampleEvent = { - event: 'sent', - time: Date.now(), - email: 'test@example.com', - mj_campaign_id: 1, - mj_contact_id: 1, - mj_message_id: 'test-message-id', - smtp_reply: 'test-smtp-reply', - MessageID: 1, - Message_GUID: 'test-message-guid', - customcampaign: 'test-custom-campaign', - CustomID: '', - Payload: generateEmailTrackingCode({ functionId: hogFunction.id, id: invocationId }), - } }) afterEach(() => { server.close() }) - describe('mailjet webhook', () => { - const sendValidEvent = async (mailjetEvent: MailjetEventBase): Promise => { - const payload = JSON.stringify(mailjetEvent) - - const res = await supertest(app) - .post(`/public/m/mailjet_webhook`) - .set({ - 'content-type': 'application/json', - }) - .send(payload) - - return res - } - - describe('validation', () => { - it('should return 403 if body is missing', async () => { - const res = await supertest(app).post(`/public/m/mailjet_webhook`).send() - - expect(res.status).toBe(403) - expect(res.body).toEqual({ - message: 'Missing request body', - }) - }) - }) - - it('should not track a metric if the hog function or flow is not found', async () => { - const mailjetEvent: MailjetEventBase = { - ...exampleEvent, - Payload: 'ph_fn_id=invalid-function-id&ph_inv_id=invalid-invocation-id', - } - const res = await sendValidEvent(mailjetEvent) - - expect(res.status).toBe(200) - expect(res.body).toEqual({ message: 'OK' }) - const messages = mockProducerObserver.getProducedKafkaMessagesForTopic(KAFKA_APP_METRICS_2) - expect(messages).toHaveLength(0) - }) - - it('should track a hog flow if given', async () => { - const mailjetEvent: MailjetEventBase = { - ...exampleEvent, - Payload: generateEmailTrackingCode({ functionId: hogFlow.id, id: invocationId }), - } - const res = await sendValidEvent(mailjetEvent) - - expect(res.status).toBe(200) - expect(res.body).toEqual({ message: 'OK' }) - const messages = mockProducerObserver.getProducedKafkaMessagesForTopic(KAFKA_APP_METRICS_2) - expect(messages).toHaveLength(1) - expect(messages[0].value).toMatchObject({ - app_source: 'hog_flow', - app_source_id: hogFlow.id, - count: 1, - instance_id: invocationId, - metric_kind: 'email', - metric_name: 'email_sent', - team_id: team.id, - }) - }) - - it.each([ - ['open', 'email_opened'], - ['click', 'email_link_clicked'], - ['bounce', 'email_bounced'], - ['spam', 'email_spam'], - ['unsub', 'email_unsubscribed'], - ] as const)('should handle valid %s event', async (event, metric) => { - const mailjetEvent: MailjetEventBase = { - ...exampleEvent, - event, - } - const res = await sendValidEvent(mailjetEvent) - - expect(res.status).toBe(200) - expect(res.body).toEqual({ message: 'OK' }) - const messages = mockProducerObserver.getProducedKafkaMessagesForTopic(KAFKA_APP_METRICS_2) - expect(messages).toHaveLength(1) - - expect(messages[0].value).toMatchObject({ - app_source: 'hog_function', - app_source_id: hogFunction.id, - count: 1, - instance_id: invocationId, - metric_kind: 'email', - metric_name: metric, - team_id: team.id, - }) - }) - }) - describe('handleEmailTrackingRedirect', () => { it('should redirect to the target url and track the click metric', async () => { const res = await supertest(app).get( diff --git a/plugin-server/src/cdp/services/messaging/email-tracking.service.ts b/plugin-server/src/cdp/services/messaging/email-tracking.service.ts index df34834b39..0212187969 100644 --- a/plugin-server/src/cdp/services/messaging/email-tracking.service.ts +++ b/plugin-server/src/cdp/services/messaging/email-tracking.service.ts @@ -13,27 +13,12 @@ import { HogFlowManagerService } from '../hogflows/hogflow-manager.service' import { HogFunctionManagerService } from '../managers/hog-function-manager.service' import { HogFunctionMonitoringService } from '../monitoring/hog-function-monitoring.service' import { SesWebhookHandler } from './helpers/ses' -import { - generateEmailTrackingCode, - generateEmailTrackingPixelUrl, - parseEmailTrackingCode, -} from './helpers/tracking-code' -import { MailjetEventType, MailjetWebhookEvent } from './types' +import { generateEmailTrackingCode, generateEmailTrackingPixelUrl } from './helpers/tracking-code' export const PIXEL_GIF = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64') const LINK_REGEX = /]*\bhref\s*=\s*(?:"(?!javascript:)([^"]*)"|'(?!javascript:)([^']*)'|(?!javascript:)([^'">\s]+))[^>]*>([\s\S]*?)<\/a>/gi -const EVENT_TYPE_TO_CATEGORY: Record = { - sent: 'email_sent', - open: 'email_opened', - click: 'email_link_clicked', - bounce: 'email_bounced', - blocked: 'email_blocked', - spam: 'email_spam', - unsub: 'email_unsubscribed', -} - const trackingEventsCounter = new Counter({ name: 'email_tracking_events_total', help: 'Total number of email tracking events received', @@ -90,7 +75,7 @@ export class EmailTrackingService { functionId?: string invocationId?: string metricName: MinimalAppMetric['metric_name'] - source: 'mailjet' | 'direct' | 'ses' + source: 'direct' | 'ses' }): Promise { if (!functionId || !invocationId) { logger.error('[EmailTrackingService] trackMetric: Invalid custom ID', { @@ -143,40 +128,6 @@ export class EmailTrackingService { }) } - public async handleMailjetWebhook(req: ModifiedRequest): Promise<{ status: number; message?: string }> { - const okResponse = { status: 200, message: 'OK' } - - if (!req.rawBody) { - return { status: 403, message: 'Missing request body' } - } - - try { - const event = req.body as MailjetWebhookEvent - - const { functionId, invocationId } = parseEmailTrackingCode(event.Payload || '') || {} - const category = EVENT_TYPE_TO_CATEGORY[event.event] - - if (!category) { - logger.error('[EmailTrackingService] trackMetric: Unmapped event type', { event }) - emailTrackingErrorsCounter.inc({ error_type: 'unmapped_event_type' }) - return { status: 400, message: 'Unmapped event type' } - } - - await this.trackMetric({ - functionId, - invocationId, - metricName: category, - source: 'mailjet', - }) - - return okResponse - } catch (error) { - emailTrackingErrorsCounter.inc({ error_type: error.name || 'unknown' }) - logger.error('[EmailService] handleWebhook: Mailjet webhook error', { error }) - throw error - } - } - public async handleSesWebhook(req: ModifiedRequest): Promise<{ status: number; message?: string }> { if (!req.body) { return { status: 403, message: 'Missing request body' } diff --git a/plugin-server/src/cdp/services/messaging/email.service.test.ts b/plugin-server/src/cdp/services/messaging/email.service.test.ts index e5648a27fb..d9dbed6c2e 100644 --- a/plugin-server/src/cdp/services/messaging/email.service.test.ts +++ b/plugin-server/src/cdp/services/messaging/email.service.test.ts @@ -6,7 +6,6 @@ import { CyclotronInvocationQueueParametersEmailType } from '~/schema/cyclotron' import { waitForExpect } from '~/tests/helpers/expectations' import { getFirstTeam, resetTestDatabase } from '~/tests/helpers/sql' import { closeHub, createHub } from '~/utils/db/hub' -import { parseJSON } from '~/utils/json-parse' import { Hub, Team } from '../../../types' import { EmailService } from './email.service' @@ -31,7 +30,7 @@ describe('EmailService', () => { let team: Team beforeEach(async () => { await resetTestDatabase() - hub = await createHub({ MAILJET_SECRET_KEY: 'mailjet-secret-key', MAILJET_PUBLIC_KEY: 'mailjet-public-key' }) + hub = await createHub({}) team = await getFirstTeam(hub) service = new EmailService(hub) mockFetch.mockClear() @@ -41,6 +40,7 @@ describe('EmailService', () => { }) describe('executeSendEmail', () => { let invocation: CyclotronJobInvocationHogFunction + let sendEmailSpy: jest.SpyInstance beforeEach(async () => { await insertIntegration(hub.postgres, team.id, { id: 1, @@ -50,7 +50,7 @@ describe('EmailService', () => { name: 'Test User', domain: 'posthog.com', verified: true, - provider: 'mailjet', + provider: 'ses', }, }) invocation = createExampleInvocation({ team_id: team.id, id: 'function-1' }) @@ -59,6 +59,11 @@ describe('EmailService', () => { stack: [], } as any invocation.queueParameters = createEmailParams({ from: { integrationId: 1, email: 'test@posthog.com' } }) + + // Mock SES sendEmail to avoid actual AWS calls + sendEmailSpy = jest.spyOn(service.ses, 'sendEmail').mockReturnValue({ + promise: () => Promise.resolve({ MessageId: 'test-message-id' }), + } as any) }) describe('integration validation', () => { beforeEach(async () => { @@ -106,14 +111,8 @@ describe('EmailService', () => { }) const result = await service.executeSendEmail(invocation) expect(result.error).toBeUndefined() - expect(parseJSON(mockFetch.mock.calls[0][1].body).Messages[0].From).toMatchInlineSnapshot( - ` - { - "Email": "test@posthog.com", - "Name": "Test User", - } - ` - ) + expect(sendEmailSpy).toHaveBeenCalled() + expect(sendEmailSpy.mock.calls[0][0].Source).toBe('"Test User" ') }) it('should validate if the email domain is not verified', async () => { invocation.queueParameters = createEmailParams({ @@ -134,21 +133,23 @@ describe('EmailService', () => { it('should send an email', async () => { const result = await service.executeSendEmail(invocation) expect(result.error).toBeUndefined() - expect(mockFetch.mock.calls[0]).toMatchInlineSnapshot( - ` - [ - "https://api.mailjet.com/v3.1/send", - { - "body": "{"Messages":[{"From":{"Email":"test@posthog.com","Name":"Test User"},"To":[{"Email":"test@example.com","Name":"Test User"}],"Subject":"Test Subject","TextPart":"Test Text","HTMLPart":"Test HTML","EventPayload":"ZnVuY3Rpb24tMTppbnZvY2F0aW9uLTE"}]}", - "headers": { - "Authorization": "Basic bWFpbGpldC1wdWJsaWMta2V5Om1haWxqZXQtc2VjcmV0LWtleQ==", - "Content-Type": "application/json", + expect(sendEmailSpy).toHaveBeenCalled() + expect(sendEmailSpy.mock.calls[0][0]).toMatchObject({ + Source: '"Test User" ', + Destination: { + ToAddresses: ['"Test User" '], + }, + Message: { + Subject: { + Data: 'Test Subject', }, - "method": "POST", - }, - ] - ` - ) + Body: { + Text: { + Data: 'Test Text', + }, + }, + }, + }) }) }) }) @@ -160,8 +161,6 @@ describe('EmailService', () => { mockFetch.mockImplementation((...args: any[]): Promise => { return actualFetch(...args) as any }) - hub.MAILJET_PUBLIC_KEY = '' - hub.MAILJET_SECRET_KEY = '' await insertIntegration(hub.postgres, team.id, { id: 1, kind: 'email', @@ -216,8 +215,6 @@ describe('EmailService', () => { mockFetch.mockImplementation((...args: any[]): Promise => { return actualFetch(...args) as any }) - hub.MAILJET_PUBLIC_KEY = '' - hub.MAILJET_SECRET_KEY = '' await insertIntegration(hub.postgres, team.id, { id: 1, kind: 'email', diff --git a/plugin-server/src/cdp/services/messaging/email.service.ts b/plugin-server/src/cdp/services/messaging/email.service.ts index fda8817498..600c6c5769 100644 --- a/plugin-server/src/cdp/services/messaging/email.service.ts +++ b/plugin-server/src/cdp/services/messaging/email.service.ts @@ -4,7 +4,6 @@ import { CyclotronJobInvocationHogFunction, CyclotronJobInvocationResult, Integr import { createAddLogFunction, logEntry } from '~/cdp/utils' import { createInvocationResult } from '~/cdp/utils/invocation-utils' import { CyclotronInvocationQueueParametersEmailType } from '~/schema/cyclotron' -import { fetch } from '~/utils/request' import { Hub } from '../../../types' import { addTrackingToEmail } from './email-tracking.service' @@ -52,13 +51,10 @@ export class EmailService { this.validateEmailDomain(integration, params) - switch (integration.config.provider ?? 'mailjet') { + switch (integration.config.provider ?? 'ses') { case 'maildev': await this.sendEmailWithMaildev(result, params) break - case 'mailjet': - await this.sendEmailWithMailjet(result, params) - break case 'ses': await this.sendEmailWithSES(result, params) break @@ -109,49 +105,6 @@ export class EmailService { params.from.name = integration.config.name } - private async sendEmailWithMailjet( - result: CyclotronJobInvocationResult, - params: CyclotronInvocationQueueParametersEmailType - ): Promise { - // First we need to lookup the email sending domain of the given team - const response = await fetch('https://api.mailjet.com/v3.1/send', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Basic ${Buffer.from( - `${this.hub.MAILJET_PUBLIC_KEY}:${this.hub.MAILJET_SECRET_KEY}` - ).toString('base64')}`, - }, - body: JSON.stringify({ - Messages: [ - { - From: { - Email: params.from.email, - Name: params.from.name, - }, - To: [ - { - Email: params.to.email, - Name: params.to.name, - }, - ], - Subject: params.subject, - TextPart: params.text, - HTMLPart: params.html, - EventPayload: generateEmailTrackingCode(result.invocation), - }, - ], - }), - }) - - // TODO: Add support for retries - in fact if it fails should we actually crash out the service? - if (response.status >= 400) { - throw new Error( - `Failed to send email to ${params.to.email} (status ${response.status}): ${await response.text()}` - ) - } - } - // Send email to local maildev instance for testing (DEBUG=1 only) private async sendEmailWithMaildev( result: CyclotronJobInvocationResult, diff --git a/plugin-server/src/cdp/services/messaging/types.ts b/plugin-server/src/cdp/services/messaging/types.ts deleted file mode 100644 index 5b0d13ec86..0000000000 --- a/plugin-server/src/cdp/services/messaging/types.ts +++ /dev/null @@ -1,186 +0,0 @@ -// Mailjet Webhook Event Types -// Based on official Mailjet documentation: https://dev.mailjet.com/email/guides/webhooks/ - -export type MailjetEventType = 'sent' | 'open' | 'click' | 'bounce' | 'blocked' | 'spam' | 'unsub' - -// Base interface shared by all events -export interface MailjetEventBase { - /** The event type */ - event: MailjetEventType - /** Unix timestamp when the event occurred */ - time: number - /** Legacy Message ID (numeric) */ - MessageID: number - /** Unique 128-bit ID for this message (UUID format) */ - Message_GUID: string - /** Recipient email address */ - email: string - /** Mailjet campaign ID */ - mj_campaign_id: number - /** Mailjet contact ID */ - mj_contact_id: number - /** Custom campaign identifier */ - customcampaign: string - /** Custom ID provided when sending (for tracking) */ - CustomID?: string - /** Custom payload provided when sending */ - Payload?: string -} - -// Specific event interfaces -export interface MailjetSentEvent extends MailjetEventBase { - event: 'sent' - /** Mailjet message ID (string format) */ - mj_message_id: string - /** SMTP server response */ - smtp_reply: string -} - -export interface MailjetOpenEvent extends MailjetEventBase { - event: 'open' - /** IP address where the open occurred */ - ip: string - /** Geographic location (country code) */ - geo: string - /** User agent string of the client */ - agent: string -} - -export interface MailjetClickEvent extends MailjetEventBase { - event: 'click' - /** The URL that was clicked */ - url: string - /** IP address where the click occurred */ - ip: string - /** Geographic location (country code) */ - geo: string - /** User agent string of the client */ - agent: string -} - -export interface MailjetBounceEvent extends MailjetEventBase { - event: 'bounce' - /** Whether this is a blocked email */ - blocked: boolean - /** Whether this is a hard bounce (permanent failure) */ - hard_bounce: boolean - /** What the error is related to (e.g., "recipient", "content") */ - error_related_to: string - /** Detailed error message */ - error: string - /** Additional comments about the bounce */ - comment?: string -} - -export interface MailjetBlockedEvent extends MailjetEventBase { - event: 'blocked' - /** What the error is related to (e.g., "recipient", "content") */ - error_related_to: string - /** Detailed error message */ - error: string -} - -export interface MailjetSpamEvent extends MailjetEventBase { - event: 'spam' - /** Source of the spam report (e.g., "JMRPP") */ - source: string -} - -export interface MailjetUnsubEvent extends MailjetEventBase { - event: 'unsub' - /** Mailjet list ID from which the user unsubscribed */ - mj_list_id: number - /** IP address where the unsubscribe occurred */ - ip: string - /** Geographic location (country code) */ - geo: string - /** User agent string of the client */ - agent: string -} - -// Union type for all possible webhook events -export type MailjetWebhookEvent = - | MailjetSentEvent - | MailjetOpenEvent - | MailjetClickEvent - | MailjetBounceEvent - | MailjetBlockedEvent - | MailjetSpamEvent - | MailjetUnsubEvent - -// Event type to category mapping (for your existing code compatibility) -export const EVENT_TYPE_TO_CATEGORY = { - sent: 'email_sent', - open: 'email_opened', - click: 'email_link_clicked', - bounce: 'email_bounced', - blocked: 'email_blocked', - spam: 'email_spam', - unsub: 'email_unsubscribed', -} as const - -// Type guards to narrow event types -export function isSentEvent(event: MailjetWebhookEvent): event is MailjetSentEvent { - return event.event === 'sent' -} - -export function isOpenEvent(event: MailjetWebhookEvent): event is MailjetOpenEvent { - return event.event === 'open' -} - -export function isClickEvent(event: MailjetWebhookEvent): event is MailjetClickEvent { - return event.event === 'click' -} - -export function isBounceEvent(event: MailjetWebhookEvent): event is MailjetBounceEvent { - return event.event === 'bounce' -} - -export function isBlockedEvent(event: MailjetWebhookEvent): event is MailjetBlockedEvent { - return event.event === 'blocked' -} - -export function isSpamEvent(event: MailjetWebhookEvent): event is MailjetSpamEvent { - return event.event === 'spam' -} - -export function isUnsubEvent(event: MailjetWebhookEvent): event is MailjetUnsubEvent { - return event.event === 'unsub' -} - -// Error categorization for bounce/blocked events -export type MailjetErrorType = - | 'recipient' // Invalid recipient - | 'content' // Content-related issue - | 'domain' // Domain-related issue - | 'reputation' // Sender reputation issue - | 'policy' // Policy violation - | 'system' // System error - | 'timeout' // Connection timeout - | 'quota' // Quota exceeded - | 'unknown' // Unknown error - -// Common bounce/error reasons -export type MailjetBounceReason = - | 'user unknown' // Email address doesn't exist - | 'domain not found' // Domain doesn't exist - | 'mailbox full' // Recipient's mailbox is full - | 'message too large' // Message exceeds size limits - | 'content blocked' // Content triggered spam filters - | 'policy violation' // Violated sending policy - | 'reputation blocked' // Sender reputation issue - | 'rate limit exceeded' // Too many messages sent - | 'connection timeout' // Connection timed out - | 'duplicate in campaign' // X-Mailjet-DeduplicateCampaign duplicate - | 'preblocked' // Address preblocked by Mailjet - | 'spam content' // Content classified as spam - | string // Other specific error messages - -// Extended interfaces with more specific error typing -export interface MailjetBounceEventTyped extends Omit { - error: MailjetBounceReason -} - -export interface MailjetBlockedEventTyped extends Omit { - error: MailjetBounceReason -} diff --git a/plugin-server/src/config/config.ts b/plugin-server/src/config/config.ts index dc4a7fefe3..1e7c4b30b3 100644 --- a/plugin-server/src/config/config.ts +++ b/plugin-server/src/config/config.ts @@ -343,11 +343,7 @@ export function getDefaultConfig(): PluginsServerConfig { GROUPS_DUAL_WRITE_COMPARISON_ENABLED: false, USE_DYNAMIC_EVENT_INGESTION_RESTRICTION_CONFIG: false, - // Workflows - MAILJET_PUBLIC_KEY: '', - MAILJET_SECRET_KEY: '', - - // SES + // SES (Workflows email sending) SES_ENDPOINT: isTestEnv() || isDevEnv() ? 'http://localhost:4566' : '', SES_ACCESS_KEY_ID: isTestEnv() || isDevEnv() ? 'test' : '', SES_SECRET_ACCESS_KEY: isTestEnv() || isDevEnv() ? 'test' : '', diff --git a/plugin-server/src/types.ts b/plugin-server/src/types.ts index d4291d6b3a..3c2131e756 100644 --- a/plugin-server/src/types.ts +++ b/plugin-server/src/types.ts @@ -473,11 +473,7 @@ export interface PluginsServerConfig extends CdpConfig, IngestionConsumerConfig, PERSON_JSONB_SIZE_ESTIMATE_ENABLE: number USE_DYNAMIC_EVENT_INGESTION_RESTRICTION_CONFIG: boolean - // Workflows - MAILJET_PUBLIC_KEY: string - MAILJET_SECRET_KEY: string - - // SES + // SES (Workflows email sending) SES_ENDPOINT: string SES_ACCESS_KEY_ID: string SES_SECRET_ACCESS_KEY: string diff --git a/posthog/api/integration.py b/posthog/api/integration.py index 164a3421f3..fc1d64dc62 100644 --- a/posthog/api/integration.py +++ b/posthog/api/integration.py @@ -40,7 +40,7 @@ from posthog.models.integration import ( class NativeEmailIntegrationSerializer(serializers.Serializer): email = serializers.EmailField() name = serializers.CharField() - provider = serializers.ChoiceField(choices=["ses", "mailjet", "maildev"] if settings.DEBUG else ["ses", "mailjet"]) + provider = serializers.ChoiceField(choices=["ses", "maildev"] if settings.DEBUG else ["ses"]) class IntegrationSerializer(serializers.ModelSerializer): diff --git a/posthog/api/test/test_integration.py b/posthog/api/test/test_integration.py index cc79a4ccce..1997d7cc21 100644 --- a/posthog/api/test/test_integration.py +++ b/posthog/api/test/test_integration.py @@ -217,12 +217,14 @@ class TestEmailIntegration: self.organization = Organization.objects.create(name="Test Org") self.team = Team.objects.create(organization=self.organization, name="Test Team") - @patch("posthog.models.integration.MailjetProvider") - def test_integration_from_domain(self, mock_mailjet_provider_class): + @patch("posthog.models.integration.SESProvider") + def test_integration_from_domain(self, mock_ses_provider_class): mock_client = MagicMock() - mock_mailjet_provider_class.return_value = mock_client + mock_ses_provider_class.return_value = mock_client - integration = EmailIntegration.create_native_integration(self.valid_config, self.team.id, self.user) + integration = EmailIntegration.create_native_integration( + {**self.valid_config, "provider": "ses"}, self.team.id, self.user + ) assert integration.kind == "email" assert integration.integration_id == self.valid_config["email"] assert integration.team_id == self.team.id @@ -231,47 +233,56 @@ class TestEmailIntegration: "name": self.valid_config["name"], "domain": "posthog.com", "verified": False, - "provider": "mailjet", + "provider": "ses", } assert integration.sensitive_config == {} assert integration.created_by == self.user mock_client.create_email_domain.assert_called_once_with("posthog.com", team_id=self.team.id) - @patch("posthog.models.integration.MailjetProvider") - def test_email_verify_returns_mailjet_result(self, mock_mailjet_provider_class): + @patch("posthog.models.integration.SESProvider") + def test_email_verify_returns_ses_result(self, mock_ses_provider_class): mock_client = MagicMock() - mock_mailjet_provider_class.return_value = mock_client + mock_ses_provider_class.return_value = mock_client # Mock the verify_email_domain method to return a test result expected_result = { "status": "pending", "dnsRecords": [ { - "type": "dkim", + "type": "verification", "recordType": "TXT", - "recordHostname": "mailjet._domainkey.example.com", - "recordValue": "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBA...", + "recordHostname": "_amazonses.posthog.com", + "recordValue": "test-verification-token", + "status": "pending", + }, + { + "type": "dkim", + "recordType": "CNAME", + "recordHostname": "token1._domainkey.posthog.com", + "recordValue": "token1.dkim.amazonses.com", "status": "pending", }, { "type": "spf", "recordType": "TXT", "recordHostname": "@", - "recordValue": "v=spf1 include:spf.mailjet.com ~all", + "recordValue": "v=spf1 include:amazonses.com ~all", "status": "pending", }, ], } mock_client.verify_email_domain.return_value = expected_result - integration = EmailIntegration.create_native_integration(self.valid_config, self.team.id, self.user) + integration = EmailIntegration.create_native_integration( + {**self.valid_config, "provider": "ses"}, self.team.id, self.user + ) email_integration = EmailIntegration(integration) verification_result = email_integration.verify() assert verification_result == expected_result - mock_client.verify_email_domain.assert_called_once_with("posthog.com") + mock_client.verify_email_domain.assert_called_once_with("posthog.com", team_id=self.team.id) integration.refresh_from_db() assert integration.config == { @@ -279,43 +290,30 @@ class TestEmailIntegration: "name": self.valid_config["name"], "domain": "posthog.com", "verified": False, - "provider": "mailjet", + "provider": "ses", } - @patch("posthog.models.integration.MailjetProvider") - def test_email_verify_updates_integration(self, mock_mailjet_provider_class): + @patch("posthog.models.integration.SESProvider") + def test_email_verify_updates_integration(self, mock_ses_provider_class): mock_client = MagicMock() - mock_mailjet_provider_class.return_value = mock_client + mock_ses_provider_class.return_value = mock_client # Mock the verify_email_domain method to return a test result expected_result = { "status": "success", - "dnsRecords": [ - { - "type": "dkim", - "recordType": "TXT", - "recordHostname": "mailjet._domainkey.example.com", - "recordValue": "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBA...", - "status": "success", - }, - { - "type": "spf", - "recordType": "TXT", - "recordHostname": "@", - "recordValue": "v=spf1 include:spf.mailjet.com ~all", - "status": "success", - }, - ], + "dnsRecords": [], } mock_client.verify_email_domain.return_value = expected_result - integration = EmailIntegration.create_native_integration(self.valid_config, self.team.id, self.user) + integration = EmailIntegration.create_native_integration( + {**self.valid_config, "provider": "ses"}, self.team.id, self.user + ) email_integration = EmailIntegration(integration) verification_result = email_integration.verify() assert verification_result == expected_result - mock_client.verify_email_domain.assert_called_once_with("posthog.com") + mock_client.verify_email_domain.assert_called_once_with("posthog.com", team_id=self.team.id) integration.refresh_from_db() assert integration.config == { @@ -323,44 +321,34 @@ class TestEmailIntegration: "name": self.valid_config["name"], "domain": "posthog.com", "verified": True, - "provider": "mailjet", + "provider": "ses", } - @patch("posthog.models.integration.MailjetProvider") - def test_email_verify_updates_all_other_integrations_with_same_domain(self, mock_mailjet_provider_class, settings): - settings.MAILJET_PUBLIC_KEY = "test_api_key" - settings.MAILJET_SECRET_KEY = "test_secret_key" + @patch("posthog.models.integration.SESProvider") + def test_email_verify_updates_all_other_integrations_with_same_domain(self, mock_ses_provider_class, settings): + settings.SES_ACCESS_KEY_ID = "test_access_key" + settings.SES_SECRET_ACCESS_KEY = "test_secret_key" mock_client = MagicMock() - mock_mailjet_provider_class.return_value = mock_client + mock_ses_provider_class.return_value = mock_client # Mock the verify_email_domain method to return a test result expected_result = { "status": "success", - "dnsRecords": [ - { - "type": "dkim", - "recordType": "TXT", - "recordHostname": "mailjet._domainkey.example.com", - "recordValue": "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBA...", - "status": "success", - }, - { - "type": "spf", - "recordType": "TXT", - "recordHostname": "@", - "recordValue": "v=spf1 include:spf.mailjet.com ~all", - "status": "success", - }, - ], + "dnsRecords": [], } mock_client.verify_email_domain.return_value = expected_result - integration1 = EmailIntegration.create_native_integration(self.valid_config, self.team.id, self.user) - integration2 = EmailIntegration.create_native_integration(self.valid_config, self.team.id, self.user) + integration1 = EmailIntegration.create_native_integration( + {**self.valid_config, "provider": "ses"}, self.team.id, self.user + ) + integration2 = EmailIntegration.create_native_integration( + {**self.valid_config, "provider": "ses"}, self.team.id, self.user + ) integrationOtherDomain = EmailIntegration.create_native_integration( { "email": "me@otherdomain.com", "name": "Me", + "provider": "ses", }, self.team.id, self.user, diff --git a/posthog/models/integration.py b/posthog/models/integration.py index 60aab2625c..407a82e811 100644 --- a/posthog/models/integration.py +++ b/posthog/models/integration.py @@ -39,7 +39,7 @@ from posthog.models.user import User from posthog.plugins.plugin_server_api import reload_integrations_on_workers from posthog.sync import database_sync_to_async -from products.workflows.backend.providers import MailjetProvider, SESProvider, TwilioProvider +from products.workflows.backend.providers import SESProvider, TwilioProvider logger = structlog.get_logger(__name__) @@ -1145,10 +1145,6 @@ class EmailIntegration: raise Exception("EmailIntegration init called with Integration with wrong 'kind'") self.integration = integration - @property - def mailjet_provider(self) -> MailjetProvider: - return MailjetProvider() - @property def ses_provider(self) -> SESProvider: return SESProvider() @@ -1158,7 +1154,7 @@ class EmailIntegration: email_address: str = config["email"] name: str = config["name"] domain: str = email_address.split("@")[1] - provider: str = config.get("provider", "mailjet") # Default to mailjet for backward compatibility + provider: str = config.get("provider", "ses") if domain in free_email_domains_list or domain in disposable_email_domains_list: raise ValidationError(f"Email domain {domain} is not supported. Please use a custom domain.") @@ -1173,13 +1169,10 @@ class EmailIntegration: if provider == "ses": ses = SESProvider() ses.create_email_domain(domain, team_id=team_id) - elif provider == "mailjet": - mailjet = MailjetProvider() - mailjet.create_email_domain(domain, team_id=team_id) elif provider == "maildev" and settings.DEBUG: pass else: - raise ValueError(f"Invalid provider: must be either 'ses' or 'mailjet'") + raise ValueError(f"Invalid provider: must be 'ses'") integration, created = Integration.objects.update_or_create( team_id=team_id, @@ -1203,40 +1196,13 @@ class EmailIntegration: return integration - @classmethod - def integration_from_keys( - cls, api_key: str, secret_key: str, team_id: int, created_by: User | None = None - ) -> Integration: - integration, created = Integration.objects.update_or_create( - team_id=team_id, - kind="email", - integration_id=api_key, - defaults={ - "config": { - "api_key": api_key, - "vendor": "mailjet", - }, - "sensitive_config": { - "secret_key": secret_key, - }, - "created_by": created_by, - }, - ) - if integration.errors: - integration.errors = "" - integration.save() - - return integration - def verify(self): domain = self.integration.config.get("domain") - provider = self.integration.config.get("provider", "mailjet") + provider = self.integration.config.get("provider", "ses") # Use the appropriate provider for verification if provider == "ses": verification_result = self.ses_provider.verify_email_domain(domain, team_id=self.integration.team_id) - elif provider == "mailjet": - verification_result = self.mailjet_provider.verify_email_domain(domain) elif provider == "maildev": verification_result = { "status": "success", diff --git a/posthog/models/test/test_integration_model.py b/posthog/models/test/test_integration_model.py index b0cb70f2b6..79cfcdcef1 100644 --- a/posthog/models/test/test_integration_model.py +++ b/posthog/models/test/test_integration_model.py @@ -5,7 +5,7 @@ from typing import Optional import pytest from freezegun import freeze_time -from posthog.test.base import BaseTest, override_settings +from posthog.test.base import BaseTest from unittest.mock import MagicMock, patch from django.db import connection @@ -699,8 +699,11 @@ class TestEmailIntegrationDomainValidation(BaseTest): assert integration.config["name"] == "Test User" assert integration.config["verified"] is False - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") - def test_duplicate_domain_in_another_team(self): + @patch("products.workflows.backend.providers.SESProvider.create_email_domain") + @patch("products.workflows.backend.providers.SESProvider.verify_email_domain") + def test_duplicate_domain_in_another_team(self, mock_create_email_domain, mock_verify_email_domain): + mock_create_email_domain.return_value = {"status": "success", "domain": "successdomain.com"} + mock_verify_email_domain.return_value = {"status": "verified", "domain": "example.com"} # Create an integration with a domain in another team other_team = Team.objects.create(organization=self.organization, name="other team") config = {"email": "user@example.com", "name": "Test User"} @@ -711,7 +714,6 @@ class TestEmailIntegrationDomainValidation(BaseTest): EmailIntegration.create_native_integration(config, team_id=self.team.id, created_by=self.user) assert "already exists in another project" in str(exc.value) - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") def test_unsupported_email_domain(self): # Test with a free email domain config = {"email": "user@gmail.com", "name": "Test User"} diff --git a/posthog/settings/__init__.py b/posthog/settings/__init__.py index 9c9d14e595..9154a7898b 100644 --- a/posthog/settings/__init__.py +++ b/posthog/settings/__init__.py @@ -33,7 +33,6 @@ from posthog.settings.ee import * from posthog.settings.ingestion import * from posthog.settings.feature_flags import * from posthog.settings.geoip import * -from posthog.settings.workflows import * from posthog.settings.metrics import * from posthog.settings.schedules import * from posthog.settings.shell_plus import * diff --git a/posthog/settings/workflows.py b/posthog/settings/workflows.py deleted file mode 100644 index 95a092814f..0000000000 --- a/posthog/settings/workflows.py +++ /dev/null @@ -1,4 +0,0 @@ -import os - -MAILJET_PUBLIC_KEY = os.getenv("MAILJET_PUBLIC_KEY", "") -MAILJET_SECRET_KEY = os.getenv("MAILJET_SECRET_KEY", "") diff --git a/products/workflows/backend/providers/__init__.py b/products/workflows/backend/providers/__init__.py index 2a40e63f18..38ccc46495 100644 --- a/products/workflows/backend/providers/__init__.py +++ b/products/workflows/backend/providers/__init__.py @@ -1,5 +1,4 @@ -from .mailjet import MailjetProvider from .ses import SESProvider from .twilio import TwilioProvider -__all__ = ["MailjetProvider", "TwilioProvider", "SESProvider"] +__all__ = ["TwilioProvider", "SESProvider"] diff --git a/products/workflows/backend/providers/mailjet.py b/products/workflows/backend/providers/mailjet.py deleted file mode 100644 index 390603617a..0000000000 --- a/products/workflows/backend/providers/mailjet.py +++ /dev/null @@ -1,211 +0,0 @@ -import re -import logging - -from django.conf import settings - -import requests -from rest_framework import exceptions - -logger = logging.getLogger(__name__) - - -class MailjetDnsResponse: - count: int - data: list[dict] - total: int - - def __init__(self, Count: int, Data: list[dict], Total: int): - self.count = Count - self.data = Data - self.total = Total - - def get_first_item(self) -> dict | None: - return self.data[0] if self.data else None - - -class MailjetValidationResponse: - validation_method: str - errors: dict - global_error: str - - def __init__(self, ValidationMethod: str, Errors: dict, GlobalError: str): - self.validation_method = ValidationMethod - self.errors = Errors - self.global_error = GlobalError - - -class MailjetConfig: - API_BASE_URL_V3: str = "https://api.mailjet.com/v3/REST" - - SENDER_ENDPOINT: str = "/sender" - SENDER_VALIDATE_ENDPOINT: str = "/validate" - DNS_ENDPOINT: str = "/dns" - DNS_CHECK_ENDPOINT: str = "/check" - - DEFAULT_HEADERS: dict[str, str] = { - "Content-Type": "application/json", - } - - -class MailjetProvider: - def __init__(self): - self.api_key = self.get_api_key() - self.api_secret = self.get_api_secret() - - @classmethod - def get_api_key(cls) -> str: - api_key = settings.MAILJET_PUBLIC_KEY - if not api_key: - raise ValueError("MAILJET_PUBLIC_KEY is not set in environment or settings") - return api_key - - @classmethod - def get_api_secret(cls) -> str: - api_secret = settings.MAILJET_SECRET_KEY - if not api_secret: - raise ValueError("MAILJET_SECRET_KEY is not set in environment or settings") - return api_secret - - def _format_dns_records( - self, required_dns_records: dict, dns_status_response: dict, is_mailjet_validated: bool - ) -> tuple[str, list[dict]]: - formatted_dns_records = [] - - # DKIM status possible values: "Not checked", "OK", "Error" - dkim_status = dns_status_response.get("DKIMStatus", "Not checked") - # SPF status possible values: "Not checked", "Not found", "OK", "Error" - spf_status = dns_status_response.get("SPFStatus", "Not checked") - overall_status = "success" if dkim_status == "OK" and spf_status == "OK" and is_mailjet_validated else "pending" - - if "DKIMRecordName" in required_dns_records and "DKIMRecordValue" in required_dns_records: - formatted_dns_records.append( - { - "type": "dkim", - "recordType": "TXT", - "recordHostname": required_dns_records.get("DKIMRecordName"), - "recordValue": required_dns_records.get("DKIMRecordValue"), - "status": "success" if dkim_status == "OK" else "pending", - } - ) - - if "SPFRecordValue" in required_dns_records: - formatted_dns_records.append( - { - "type": "spf", - "recordType": "TXT", - "recordHostname": "@", - "recordValue": required_dns_records.get("SPFRecordValue"), - "status": "success" if spf_status == "OK" else "pending", - } - ) - - if "OwnerShipToken" in required_dns_records and "OwnerShipTokenRecordName" in required_dns_records: - formatted_dns_records.append( - { - "type": "ownership", - "recordType": "TXT", - "recordHostname": required_dns_records.get("OwnerShipTokenRecordName"), - "recordValue": required_dns_records.get("OwnerShipToken"), - "status": "success" if is_mailjet_validated else "pending", - } - ) - - return overall_status, formatted_dns_records - - def _get_domain_dns_records(self, domain: str): - """ - Get DNS records for a domain (Mailjet verification, DKIM + SPF verification status) - - Reference: https://dev.mailjet.com/email/reference/sender-addresses-and-domains/dns/ - """ - url = f"{MailjetConfig.API_BASE_URL_V3}{MailjetConfig.DNS_ENDPOINT}/{domain}" - - try: - response = requests.get(url, auth=(self.api_key, self.api_secret), headers=MailjetConfig.DEFAULT_HEADERS) - response.raise_for_status() - return MailjetDnsResponse(**response.json()).get_first_item() - except requests.exceptions.RequestException as e: - logger.exception(f"Mailjet API error fetching DNS records: {e}") - raise - - def _check_domain_dns_records(self, domain: str): - """ - Trigger a check for the current status of DKIM and SPF records for a domain - - Reference: https://dev.mailjet.com/email/reference/sender-addresses-and-domains/dns/#v3_get_dns_check - """ - url = f"{MailjetConfig.API_BASE_URL_V3}{MailjetConfig.DNS_ENDPOINT}/{domain}{MailjetConfig.DNS_CHECK_ENDPOINT}" - - try: - response = requests.post(url, auth=(self.api_key, self.api_secret), headers=MailjetConfig.DEFAULT_HEADERS) - response.raise_for_status() - return MailjetDnsResponse(**response.json()).get_first_item() - except requests.exceptions.RequestException as e: - logger.exception(f"Mailjet API error checking DNS records: {e}") - raise - - def _validate_email_sender(self, sender_id: str): - """ - Trigger validation of the sender in Mailjet - - Reference: https://dev.mailjet.com/email/reference/sender-addresses-and-domains/sender/#v3_post_sender_sender_ID_validate - """ - url = f"{MailjetConfig.API_BASE_URL_V3}{MailjetConfig.SENDER_ENDPOINT}/{sender_id}{MailjetConfig.SENDER_VALIDATE_ENDPOINT}" - try: - response = requests.post(url, auth=(self.api_key, self.api_secret), headers=MailjetConfig.DEFAULT_HEADERS) - response.raise_for_status() - validation_response = MailjetValidationResponse(**response.json()) - return validation_response.errors.get("DNSValidationError") is None - except requests.exceptions.RequestException as e: - logger.exception(f"Mailjet API error checking DNS records: {e}") - raise - - def create_email_domain(self, domain: str, team_id: int): - """ - Create a new sender domain in Mailjet - - Reference: https://dev.mailjet.com/email/reference/sender-addresses-and-domains/sender/#v3_post_sender - """ - # Validate the domain contains valid characters for a domain name - DOMAIN_REGEX = r"(?i)^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$" - if not re.match(DOMAIN_REGEX, domain): - raise exceptions.ValidationError("Please enter a valid domain or subdomain name.") - - sender_domain = f"*@{domain}" - - url = f"{MailjetConfig.API_BASE_URL_V3}{MailjetConfig.SENDER_ENDPOINT}" - - # Use the team ID and domain to create a unique sender name on Mailjet side. - # This isn't used by PostHog, but can be helpful when looking at senders in the Mailjet console. - delimited_sender_name = f"{team_id}|{domain}" - # EmailType = "unknown" as both transactional and workflow emails may be sent from this domain - payload = {"EmailType": "unknown", "Email": sender_domain, "Name": delimited_sender_name} - - try: - response = requests.post( - url, auth=(self.api_key, self.api_secret), headers=MailjetConfig.DEFAULT_HEADERS, json=payload - ) - - if ( - response.status_code == 400 - and "There is an already existing" in response.text - and "sender with the same email" in response.text - ): - return - - except requests.exceptions.RequestException as e: - logger.exception(f"Mailjet API error creating sender domain: {e}") - raise - - def verify_email_domain(self, domain: str): - """ - Verify the email domain by checking DNS records status. - """ - required_dns_records = self._get_domain_dns_records(domain) - dns_status_response = self._check_domain_dns_records(domain) - is_mailjet_validated = self._validate_email_sender(f"*@{domain}") - overall_status, formatted_dns_records = self._format_dns_records( - required_dns_records, dns_status_response, is_mailjet_validated - ) - - return {"status": overall_status, "dnsRecords": formatted_dns_records} diff --git a/products/workflows/backend/test/test_mailjet_provider.py b/products/workflows/backend/test/test_mailjet_provider.py deleted file mode 100644 index 4e8b2b6013..0000000000 --- a/products/workflows/backend/test/test_mailjet_provider.py +++ /dev/null @@ -1,254 +0,0 @@ -from unittest.mock import MagicMock, patch - -from django.test import TestCase, override_settings - -from rest_framework import exceptions - -from posthog.models import Organization, Team - -from products.workflows.backend.providers.mailjet import MailjetProvider - - -class TestMailjetProvider(TestCase): - def setUp(self): - self.organization = Organization.objects.create(name="test") - self.team = Team.objects.create(organization=self.organization) - self.domain = "example.com" - self.mock_dns_response = { - "DKIMRecordName": "mailjet._domainkey.example.com", - "DKIMRecordValue": "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBA...", - "DKIMStatus": "Not checked", - "SPFRecordValue": "v=spf1 include:spf.mailjet.com ~all", - "SPFStatus": "Not checked", - "OwnerShipToken": "123a4bc56d7890efg123h4ijk56l78mn", - "OwnerShipTokenRecordName": "mailjet._123a4bc56.example.com.", - } - - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") - def test_init_with_valid_credentials(self): - provider = MailjetProvider() - self.assertEqual(provider.api_key, "test_api_key") - self.assertEqual(provider.api_secret, "test_secret_key") - - @override_settings(MAILJET_PUBLIC_KEY="", MAILJET_SECRET_KEY="test_secret_key") - def test_init_with_missing_api_key(self): - with self.assertRaises(ValueError) as context: - MailjetProvider() - self.assertEqual(str(context.exception), "MAILJET_PUBLIC_KEY is not set in environment or settings") - - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="") - def test_init_with_missing_secret_key(self): - with self.assertRaises(ValueError) as context: - MailjetProvider() - self.assertEqual(str(context.exception), "MAILJET_SECRET_KEY is not set in environment or settings") - - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") - def test_format_dns_records(self): - provider = MailjetProvider() - status, records = provider._format_dns_records( - self.mock_dns_response, - self.mock_dns_response, - False, - ) - - self.assertEqual(status, "pending") - self.assertEqual(len(records), 3) - - dkim_record = next((r for r in records if r["type"] == "dkim"), {}) - spf_record = next((r for r in records if r["type"] == "spf"), {}) - ownership_record = next((r for r in records if r["type"] == "ownership"), {}) - - self.assertEqual(dkim_record["recordType"], "TXT") - self.assertEqual(dkim_record["recordHostname"], self.mock_dns_response["DKIMRecordName"]) - self.assertEqual(dkim_record["recordValue"], self.mock_dns_response["DKIMRecordValue"]) - self.assertEqual(dkim_record["status"], "pending") - - self.assertEqual(spf_record["recordType"], "TXT") - self.assertEqual(spf_record["recordHostname"], "@") - self.assertEqual(spf_record["recordValue"], self.mock_dns_response["SPFRecordValue"]) - self.assertEqual(spf_record["status"], "pending") - - self.assertEqual(ownership_record["recordType"], "TXT") - self.assertEqual(ownership_record["recordHostname"], self.mock_dns_response["OwnerShipTokenRecordName"]) - self.assertEqual(ownership_record["recordValue"], self.mock_dns_response["OwnerShipToken"]) - self.assertEqual(ownership_record["status"], "pending") - - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") - def test_format_dns_records_verified(self): - provider = MailjetProvider() - verified_response = self.mock_dns_response.copy() - verified_response["DKIMStatus"] = "OK" - verified_response["SPFStatus"] = "OK" - - status, records = provider._format_dns_records(verified_response, verified_response, True) - - self.assertEqual(status, "success") - self.assertEqual(records[0]["status"], "success") - self.assertEqual(records[1]["status"], "success") - - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") - def test_format_dns_records_partial_verification(self): - provider = MailjetProvider() - partial_response = self.mock_dns_response.copy() - partial_response["DKIMStatus"] = "OK" - - status, records = provider._format_dns_records(partial_response, partial_response, True) - - self.assertEqual(status, "pending") - self.assertEqual(records[0]["status"], "success") - self.assertEqual(records[1]["status"], "pending") - - @patch("requests.post") - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") - def test_create_email_domain_success(self, mock_post): - mock_response = MagicMock() - mock_response.json.return_value = {"Count": 1, "Data": [{"Success": True}], "Total": 1} - mock_response.raise_for_status.return_value = None - mock_post.return_value = mock_response - - provider = MailjetProvider() - provider.create_email_domain(self.domain, self.team.id) - mock_post.assert_called_once() - - @patch("requests.post") - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") - def test_create_email_domain_invalid_domain(self, mock_post): - provider = MailjetProvider() - invalid_domains = ["", "invalid domain", "no-tld", "@example.com"] - - for domain in invalid_domains: - with self.assertRaises(exceptions.ValidationError): - provider.create_email_domain(domain, self.team.id) - - mock_post.assert_not_called() - - @patch("requests.post") - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") - def test_create_email_domain_request_exception(self, mock_post): - mock_post.side_effect = Exception("API Error") - - provider = MailjetProvider() - - with self.assertRaises(Exception): - provider.create_email_domain(self.domain, self.team.id) - - @patch("requests.get") - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") - def test_get_domain_dns_records_success(self, mock_get): - mock_response = MagicMock() - mock_response.json.return_value = {"Count": 1, "Data": [self.mock_dns_response], "Total": 1} - mock_response.raise_for_status.return_value = None - mock_get.return_value = mock_response - - provider = MailjetProvider() - result = provider._get_domain_dns_records(self.domain) - - self.assertEqual(result, self.mock_dns_response) - mock_get.assert_called_once() - args, kwargs = mock_get.call_args - self.assertEqual(kwargs["auth"], ("test_api_key", "test_secret_key")) - self.assertEqual(kwargs["headers"], {"Content-Type": "application/json"}) - self.assertEqual(args[0], f"https://api.mailjet.com/v3/REST/dns/{self.domain}") - - @patch("requests.get") - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") - def test_get_domain_dns_records_request_exception(self, mock_get): - mock_get.side_effect = Exception("API Error") - - provider = MailjetProvider() - - with self.assertRaises(Exception): - provider._get_domain_dns_records(self.domain) - - @patch("requests.post") - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") - def test_check_domain_dns_records_success(self, mock_post): - mock_response = MagicMock() - mock_response.json.return_value = {"Count": 1, "Data": [self.mock_dns_response], "Total": 1} - mock_response.raise_for_status.return_value = None - mock_post.return_value = mock_response - - provider = MailjetProvider() - result = provider._check_domain_dns_records(self.domain) - - self.assertEqual(result, self.mock_dns_response) - mock_post.assert_called_once() - args, kwargs = mock_post.call_args - self.assertEqual(kwargs["auth"], ("test_api_key", "test_secret_key")) - self.assertEqual(kwargs["headers"], {"Content-Type": "application/json"}) - self.assertEqual(args[0], f"https://api.mailjet.com/v3/REST/dns/{self.domain}/check") - - @patch("requests.get") - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") - def test_check_domain_dns_records_request_exception(self, mock_get): - mock_get.side_effect = Exception("API Error") - - provider = MailjetProvider() - - with self.assertRaises(Exception): - provider._check_domain_dns_records(self.domain) - - @patch.object(MailjetProvider, "_validate_email_sender") - @patch.object(MailjetProvider, "_check_domain_dns_records") - @patch.object(MailjetProvider, "_get_domain_dns_records") - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") - def test_verify_email_domain(self, mock_get_dns, mock_check_dns, mock_validate): - verified_dns_response = self.mock_dns_response.copy() - verified_dns_response["DKIMStatus"] = "OK" - verified_dns_response["SPFStatus"] = "OK" - mock_get_dns.return_value = verified_dns_response - mock_check_dns.return_value = verified_dns_response - mock_validate.return_value = True - - provider = MailjetProvider() - result = provider.verify_email_domain(self.domain) - - mock_check_dns.assert_called_once_with(self.domain) - mock_get_dns.assert_called_once_with(self.domain) - - self.assertEqual(result["status"], "success") - self.assertEqual(len(result["dnsRecords"]), 3) - self.assertTrue(all(record["status"] == "success" for record in result["dnsRecords"])) - - @patch.object(MailjetProvider, "_validate_email_sender") - @patch.object(MailjetProvider, "_check_domain_dns_records") - @patch.object(MailjetProvider, "_get_domain_dns_records") - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") - def test_verify_email_domain_not_verified(self, mock_get_dns, mock_check_dns, mock_validate): - mock_get_dns.return_value = self.mock_dns_response - mock_check_dns.return_value = self.mock_dns_response - mock_validate.return_value = False - - provider = MailjetProvider() - result = provider.verify_email_domain(self.domain) - - mock_check_dns.assert_called_once_with(self.domain) - mock_get_dns.assert_called_once_with(self.domain) - - self.assertEqual(result["status"], "pending") - self.assertEqual(len(result["dnsRecords"]), 3) - self.assertTrue(all(record["status"] == "pending" for record in result["dnsRecords"])) - - @patch.object(MailjetProvider, "_validate_email_sender") - @patch.object(MailjetProvider, "_check_domain_dns_records") - @patch.object(MailjetProvider, "_get_domain_dns_records") - @override_settings(MAILJET_PUBLIC_KEY="test_api_key", MAILJET_SECRET_KEY="test_secret_key") - def test_verify_email_domain_validation_failed(self, mock_get_dns, mock_check_dns, mock_validate): - verified_dns_response = self.mock_dns_response.copy() - verified_dns_response["DKIMStatus"] = "OK" - verified_dns_response["SPFStatus"] = "OK" - mock_get_dns.return_value = verified_dns_response - mock_check_dns.return_value = verified_dns_response - - mock_validate.return_value = False - - provider = MailjetProvider() - result = provider.verify_email_domain(self.domain) - - mock_validate.assert_called_once_with(f"*@{self.domain}") - self.assertEqual(result["status"], "pending") - self.assertEqual(len(result["dnsRecords"]), 3) - - self.assertTrue(result["dnsRecords"][0]["status"] == "success") - self.assertTrue(result["dnsRecords"][1]["status"] == "success") - self.assertTrue(result["dnsRecords"][2]["status"] == "pending") diff --git a/products/workflows/frontend/Channels/EmailSetup/EmailSetupModal.tsx b/products/workflows/frontend/Channels/EmailSetup/EmailSetupModal.tsx index 87d8c817e1..273273e9f0 100644 --- a/products/workflows/frontend/Channels/EmailSetup/EmailSetupModal.tsx +++ b/products/workflows/frontend/Channels/EmailSetup/EmailSetupModal.tsx @@ -27,7 +27,6 @@ export const EmailSetupModal = (props: EmailSetupModalLogicProps): JSX.Element = diff --git a/products/workflows/frontend/Channels/EmailSetup/emailSetupModalLogic.ts b/products/workflows/frontend/Channels/EmailSetup/emailSetupModalLogic.ts index b7022b65d1..517efe8652 100644 --- a/products/workflows/frontend/Channels/EmailSetup/emailSetupModalLogic.ts +++ b/products/workflows/frontend/Channels/EmailSetup/emailSetupModalLogic.ts @@ -26,7 +26,7 @@ export interface DnsRecord { export interface EmailSenderFormType { email: string name: string - provider: 'ses' | 'mailjet' | 'maildev' + provider: 'ses' | 'maildev' } const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/i