mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
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:
@@ -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),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' : '',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 *
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import os
|
||||
|
||||
MAILJET_PUBLIC_KEY = os.getenv("MAILJET_PUBLIC_KEY", "")
|
||||
MAILJET_SECRET_KEY = os.getenv("MAILJET_SECRET_KEY", "")
|
||||
@@ -1,5 +1,4 @@
|
||||
from .mailjet import MailjetProvider
|
||||
from .ses import SESProvider
|
||||
from .twilio import TwilioProvider
|
||||
|
||||
__all__ = ["MailjetProvider", "TwilioProvider", "SESProvider"]
|
||||
__all__ = ["TwilioProvider", "SESProvider"]
|
||||
|
||||
@@ -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}
|
||||
@@ -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")
|
||||
@@ -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' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user