chore(workflows): remove Mailjet as email provider for Workflows (#41141)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Haven
2025-11-10 10:43:59 -06:00
committed by GitHub
parent 331865023a
commit fa9b9401d3
21 changed files with 93 additions and 1034 deletions

View File

@@ -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),

View File

@@ -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]

View File

@@ -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<any> => {
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<any> => {

View File

@@ -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<supertest.Response> => {
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(

View File

@@ -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 =
/<a\b[^>]*\bhref\s*=\s*(?:"(?!javascript:)([^"]*)"|'(?!javascript:)([^']*)'|(?!javascript:)([^'">\s]+))[^>]*>([\s\S]*?)<\/a>/gi
const EVENT_TYPE_TO_CATEGORY: Record<MailjetEventType, MinimalAppMetric['metric_name'] | undefined> = {
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<void> {
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' }

View File

@@ -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" <test@posthog.com>')
})
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" <test@posthog.com>',
Destination: {
ToAddresses: ['"Test User" <test@example.com>'],
},
Message: {
Subject: {
Data: 'Test Subject',
},
"method": "POST",
},
]
`
)
Body: {
Text: {
Data: 'Test Text',
},
},
},
})
})
})
})
@@ -160,8 +161,6 @@ describe('EmailService', () => {
mockFetch.mockImplementation((...args: any[]): Promise<any> => {
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<any> => {
return actualFetch(...args) as any
})
hub.MAILJET_PUBLIC_KEY = ''
hub.MAILJET_SECRET_KEY = ''
await insertIntegration(hub.postgres, team.id, {
id: 1,
kind: 'email',

View File

@@ -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<CyclotronJobInvocationHogFunction>,
params: CyclotronInvocationQueueParametersEmailType
): Promise<void> {
// 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<CyclotronJobInvocationHogFunction>,

View File

@@ -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<MailjetBounceEvent, 'error'> {
error: MailjetBounceReason
}
export interface MailjetBlockedEventTyped extends Omit<MailjetBlockedEvent, 'error'> {
error: MailjetBounceReason
}

View File

@@ -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' : '',

View File

@@ -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

View File

@@ -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):

View File

@@ -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,

View File

@@ -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",

View File

@@ -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"}

View File

@@ -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 *

View File

@@ -1,4 +0,0 @@
import os
MAILJET_PUBLIC_KEY = os.getenv("MAILJET_PUBLIC_KEY", "")
MAILJET_SECRET_KEY = os.getenv("MAILJET_SECRET_KEY", "")

View File

@@ -1,5 +1,4 @@
from .mailjet import MailjetProvider
from .ses import SESProvider
from .twilio import TwilioProvider
__all__ = ["MailjetProvider", "TwilioProvider", "SESProvider"]
__all__ = ["TwilioProvider", "SESProvider"]

View File

@@ -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}

View File

@@ -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")

View File

@@ -27,7 +27,6 @@ export const EmailSetupModal = (props: EmailSetupModalLogicProps): JSX.Element =
<LemonSelect
options={[
{ value: 'ses', label: 'AWS SES' },
{ value: 'mailjet', label: 'Mailjet' },
{ value: 'maildev', label: 'Maildev' },
]}
/>

View File

@@ -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