Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
102 commits
Select commit Hold shift + click to select a range
969011f
feat(cdp): deliver a code send's content on its own transactional ema…
Silthus Oct 29, 2026
920e7c9
fix(cdp): read the primary once for concurrent jobs of a missing work…
Silthus Nov 1, 2026
4c7375d
Merge remote-tracking branch 'upstream/master' into email-v2/01-cdp-c…
Silthus Nov 2, 2026
b5889c9
Merge remote-tracking branch 'upstream/master' into email-v2/01-cdp-c…
Silthus Nov 2, 2026
27bfa23
fix(cdp): mint no distinct_id in tracking codes of a capture-off send
Silthus Nov 2, 2026
b9b0865
test(cdp): cover the capture choice in dev/test pixel and redirect codes
Silthus Nov 3, 2026
aace308
feat(cdp): send cc and bcc on a transactional code send
Silthus Nov 3, 2026
7cb93d9
feat(cdp): deliver transactional email attachments from a side table
Silthus Nov 3, 2026
f79130a
perf(cdp): sample the attachment row gauge without a table scan
Silthus Nov 3, 2026
28d56e7
refactor(cdp): drop the dormant v3 tracking-code format
Silthus Nov 3, 2026
0b50f42
feat(cdp): add the email attachments table to cyclotron
Silthus Nov 3, 2026
86c7ab6
Merge branch 'email-v2/13b-cdp-email-attachments-table' into email-v2…
Silthus Nov 3, 2026
e7fc40c
fix(cdp): cache and batch confirmed primary misses
Silthus Nov 3, 2026
8095678
fix(cdp): delete attachment files on every final send outcome
Silthus Nov 3, 2026
6df3613
test(cdp): cover attachment cleanup for finished and rescheduled runs
Silthus Nov 3, 2026
b7e2f40
fix(cdp): cache primary hits during replica lag
Silthus Nov 3, 2026
4255bac
fix(cdp): keep no primary result from a read a reload overtook
Silthus Nov 3, 2026
38198c3
test(cdp): check attachment cleanup against stored rows
Silthus Nov 3, 2026
9a9eb8d
fix(cdp): resolve email feedback for a workflow only the primary has
Silthus Nov 3, 2026
4f0d3d7
fix(cdp): keep workflows found when a feedback lookup fails
Silthus Nov 3, 2026
ac6e18c
Merge remote-tracking branch 'origin/master' into rhs3-cdp-code-send-fix
Silthus Nov 3, 2026
adfb3d7
Merge commit '9a9eb8d273ba57d681252a8bdce3fdcf0b015c47' into email-v2…
Silthus Nov 3, 2026
f0b7f43
Merge remote-tracking branch 'origin/email-v2/13-cdp-code-send-copies…
Silthus Nov 3, 2026
b3d3cc0
Merge remote-tracking branch 'origin/email-v2/13b-cdp-email-attachmen…
Silthus Nov 3, 2026
abbf035
fix(cdp): count a failed primary lookup for email feedback
Silthus Nov 3, 2026
d41662a
Merge branch 'email-v2/01-cdp-code-send' into email-v2/13-cdp-code-se…
Silthus Nov 3, 2026
9bfe581
Merge branch 'email-v2/13-cdp-code-send-copies' into email-v2/13b-cdp…
Silthus Nov 3, 2026
e79d708
Merge branch 'email-v2/13b-cdp-email-attachments-table' into email-v2…
Silthus Nov 3, 2026
f72b960
chore(cdp): test that attachments survive an ses throttle retry
Silthus Nov 3, 2026
e2fd437
chore: merge master into email-v2/01-cdp-code-send
Silthus Nov 3, 2026
fc855c3
fix(cdp): refuse a code send field this version does not know
Silthus Nov 3, 2026
f470194
Merge commit 'fc855c3e41d6f00d71db0a76e43b65e1b722a2a0' into HEAD
Silthus Nov 3, 2026
cf413fe
Merge commit 'f470194f9c4' into email-v2/13b-cdp-email-attachments-table
Silthus Nov 3, 2026
98778c2
Merge commit 'cf413fe35efb1c3efc6fa893622b16a64b18e795' into email-v2…
Silthus Nov 3, 2026
34347b2
fix(cdp): refuse unknown fields inside a code send's content, sender …
Silthus Nov 3, 2026
5307e9b
Merge commit '34347b2339949240c61994107d0eb9b077fb3ef0' into HEAD
Silthus Nov 3, 2026
5551b1e
Merge commit '5307e9bae1f' into email-v2/13b-cdp-email-attachments-table
Silthus Nov 3, 2026
e4589c9
Merge commit '5551b1ed921e42d6fa7dc74100b4de465b43ae12' into email-v2…
Silthus Nov 3, 2026
474a766
chore(cdp): drop the comment on the strict send schema
Silthus Nov 3, 2026
ef33e2b
Merge commit '474a7662c3014855a00cf84caea7d861d7d77e41' into HEAD
Silthus Nov 3, 2026
6ffe40f
Merge commit 'ef33e2b9d16' into email-v2/13b-cdp-email-attachments-table
Silthus Nov 3, 2026
7762f3f
Merge commit '6ffe40fd8de749a668cdba16ffddfc102b771fc1' into email-v2…
Silthus Nov 3, 2026
98d5b71
chore(cdp): drop comments from the copy recipient code
Silthus Nov 3, 2026
684e036
Merge commit '98d5b71392f136f15637bd6eede263fbbc952559' into email-v2…
Silthus Nov 3, 2026
ddeee0d
chore(cdp): drop comments from the email attachments migration
Silthus Nov 3, 2026
712800b
Merge commit 'ddeee0dfc6f' into email-v2/14-cdp-code-send-attachments
Silthus Nov 3, 2026
b4454f4
chore(cdp): drop comments from the email attachments code
Silthus Nov 3, 2026
272024a
fix(cdp): accept only a code-send token on the code send route
Silthus Nov 3, 2026
8be4b84
Merge remote-tracking branch 'origin/master' into email-v2/01-cdp-cod…
Silthus Nov 3, 2026
ddea17d
refactor(cdp): lay a code send over the email step in the send hand-off
Silthus Nov 3, 2026
6513306
Merge remote-tracking branch 'origin/email-v2/01-cdp-code-send' into …
Silthus Nov 3, 2026
5e58ffe
Merge branch 'email-v2/13-cdp-code-send-copies' into email-v2/13b-cdp…
Silthus Nov 3, 2026
bb6b915
Merge branch 'email-v2/13b-cdp-email-attachments-table' into email-v2…
Silthus Nov 3, 2026
1be8e50
fix(cdp): replay a code send on rerun only when the request allows it
Silthus Nov 3, 2026
b5c9e59
refactor(cdp): read the capture choice in one place
Silthus Nov 3, 2026
76243a7
fix(cdp): check a code send's copies before skipping a suppressed rec…
Silthus Nov 3, 2026
aa70789
Merge branch 'email-v2/13-cdp-code-send-copies' into email-v2/13b-cdp…
Silthus Nov 3, 2026
0d2fa00
Merge branch 'email-v2/13b-cdp-email-attachments-table' into email-v2…
Silthus Nov 3, 2026
2eac306
fix(cdp): keep a code send's choices when a step resumes from saved s…
Silthus Nov 3, 2026
3ac5bf5
Merge remote-tracking branch 'origin/email-v2/01-cdp-code-send' into …
Silthus Nov 3, 2026
2c9582e
Merge branch 'email-v2/13-cdp-code-send-copies' into email-v2/13b-cdp…
Silthus Nov 3, 2026
36153cc
Merge branch 'email-v2/13b-cdp-email-attachments-table' into email-v2…
Silthus Nov 3, 2026
0aa7884
fix(cdp): accept every dot-atom recipient the email pipeline can send to
Silthus Nov 3, 2026
8766c36
Merge commit '0aa7884cfcbe10a9592a8472cf7888bcd8a8c6f9' into HEAD
Silthus Nov 3, 2026
db2db99
fix(cdp): check copy addresses with the recipient address pattern
Silthus Nov 3, 2026
c2e4535
Merge remote-tracking branch 'origin/email-v2/13-cdp-code-send-copies…
Silthus Nov 3, 2026
0b84b75
Merge remote-tracking branch 'origin/email-v2/13b-cdp-email-attachmen…
Silthus Nov 3, 2026
3639e5d
fix(cdp): keep dots between the characters of a recipient's local part
Silthus Nov 3, 2026
bc8b605
Merge remote-tracking branch 'origin/email-v2/01-cdp-code-send' into …
Silthus Nov 3, 2026
9277e7e
Merge remote-tracking branch 'origin/email-v2/13-cdp-code-send-copies…
Silthus Nov 3, 2026
906d90e
Merge remote-tracking branch 'origin/email-v2/13b-cdp-email-attachmen…
Silthus Nov 3, 2026
ccdd9ff
fix(cdp): tell the user why a sensitive run was not re-run
Silthus Nov 3, 2026
75f7a34
Merge remote-tracking branch 'origin/master' into HEAD
Silthus Nov 3, 2026
20fb623
fix(cdp): call a skipped sensitive run's content a message, not an email
Silthus Nov 3, 2026
627b966
Merge commit '20fb6230d8525f962fe664024c457f690b5e6eab' into HEAD
Silthus Nov 3, 2026
ddc7ece
Merge remote-tracking branch 'origin/email-v2/13-cdp-code-send-copies…
Silthus Nov 3, 2026
5f2bdbf
Merge remote-tracking branch 'origin/email-v2/13b-cdp-email-attachmen…
Silthus Nov 3, 2026
eb26545
chore(cdp): send the executor's email routing tests through a mocked SES
Silthus Nov 3, 2026
8144a0e
Merge commit 'eb26545793431ef5b714c167b78f90775d3b4124' into HEAD
Silthus Nov 3, 2026
cdfdcb2
Merge commit '8144a0e1929' into email-v2/13b-cdp-email-attachments-table
Silthus Nov 3, 2026
b3366fa
Merge commit 'cdfdcb24897' into email-v2/14-cdp-code-send-attachments
Silthus Nov 3, 2026
b926744
chore(cdp): share the lazy pool between conversion watchers and attac…
Silthus Nov 3, 2026
a239b6b
chore(cdp): keep the conversion watcher comments where they were
Silthus Nov 3, 2026
6f4136c
fix(cdp): refuse a code send token on the template send route
Silthus Nov 4, 2026
52a086a
Merge #127122 6f4136c into email-v2/13-cdp-code-send-copies
Silthus Nov 4, 2026
b6e32d3
Merge #127985 into email-v2/13b-cdp-email-attachments-table
Silthus Nov 4, 2026
66be68d
Merge #127995 into email-v2/14-cdp-code-send-attachments
Silthus Nov 4, 2026
4a75f30
fix(cdp): retry an email whose attachment files fail to load
Silthus Nov 4, 2026
829067d
fix(cdp): keep attachment files when the queue insert fails
Silthus Nov 4, 2026
2723f32
fix(cdp): delete attachment files only after the job result is written
Silthus Nov 4, 2026
c3638f3
fix(cdp): refuse attachment types that SES or Gmail rejects
Silthus Nov 4, 2026
c3422d0
chore(cdp): size attachments with Buffer.byteLength
Silthus Nov 4, 2026
2a930a2
fix(cdp): scope email attachment rows to the job's team
Silthus Nov 4, 2026
53d6c26
Merge master into email-v2/13-cdp-code-send-copies
Silthus Nov 4, 2026
36b3a58
Merge email-v2/13-cdp-code-send-copies into email-v2/13b-cdp-email-at…
Silthus Nov 4, 2026
d069769
Merge email-v2/13b-cdp-email-attachments-table into email-v2/14-cdp-c…
Silthus Nov 4, 2026
8087a87
Merge master into email-v2/14-cdp-code-send-attachments
Silthus Nov 4, 2026
cb6469d
fix(cdp): key a code send with files on the sender the caller chose
Silthus Nov 4, 2026
dab7651
fix(cdp): keep the files of a send whose job another worker holds
Silthus Nov 5, 2026
6f1c414
fix(cdp): refuse attachment headers SES rejects and skip locked sweep…
Silthus Nov 5, 2026
900e21f
chore(cdp): cover the attachment sweep around a locked row
Silthus Nov 5, 2026
3bf8a16
fix(cdp): bound attachment content before the base64 check
Silthus Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion nodejs/src/cdp/async-functions/send-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { registerAsyncFunction } from '../async-function-registry'

registerAsyncFunction('sendEmail', {
execute: (args, context, result) => {
const { attachments: _onlyAnAcceptedSendHasFiles, ...stepEmail } = args[0] ?? {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Blocked attachment extensions are bypassed with a trailing dot or space in the filename

A sender with a code-send key can attach an executable that the blocked-extension list is supposed to refuse. The strip added here (const { attachments: ... } = args[0]) makes the AttachmentSchema filename check in nodejs/src/cdp/services/hogflows/transactional-email.ts:151-157 the sole gate on every delivered attachment's filename, and that check derives the extension from the text after the last dot with no trimming: "invoice.exe." yields an empty extension and "invoice.exe " yields "exe ", neither of which is in BLOCKED_ATTACHMENT_EXTENSIONS (transactional-email.ts:153-154). Windows strips trailing dots and spaces when the recipient saves the file, so the delivered attachment becomes invoice.exe. The filename regexes at transactional-email.ts:148 and :140 block path separators and control characters but not a trailing space or dot.

How:

  1. Call the transactional email send endpoint with a valid send key.
  2. In send.attachments, include a file named invoice.exe. (or invoice.exe ) with base64 PE content.
  3. AttachmentSchema passes: name.lastIndexOf('.') finds the trailing dot and name.slice(dot + 1) is "" (or "exe "), which the blocked set does not contain, so the API stores the file and queues the send.
  4. SES delivers the executable; a recipient saving it on Windows gets invoice.exe.

Fix: In the filename refine at transactional-email.ts:151-157, trim trailing dots and whitespace before deriving the extension (and reject the name if the trimmed result is empty).


React with 👍 if useful or 👎 if not

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid as a gap in this check, but no file gets past it. The public entry point is Django's code send, and #127988 refuses invoice.exe. and invoice.exe with blocked_file_type before it calls cdp-api: it strips trailing dots and spaces the way Windows does when it saves the file. cdp-api's /send is internal, and since 2a930a2 a workflow email step can't list files. Making the cdp check agree with Django's is a follow-up for #127985's schema, so this stack doesn't need another round of proofs for it.

result.invocation.queueParameters = CyclotronInvocationQueueParametersEmailSchema.parse({
...args[0],
...stepEmail,
...acceptedSendEmailFields(context.invocation.state.send),
type: 'email',
})
Expand Down
136 changes: 136 additions & 0 deletions nodejs/src/cdp/cdp-api.serial.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
import { createMockJobQueue } from '../../tests/helpers/mocks/job-queue.mock'
import { mockFetch } from '../../tests/helpers/mocks/request.mock'

import { createHash } from 'crypto'
import { Server } from 'http'
import jwt from 'jsonwebtoken'
import { Pool } from 'pg'
import supertest from 'supertest'
import express from 'ultimate-express'

Expand Down Expand Up @@ -41,6 +43,7 @@ import {
transactionalEmailRequestsTotal,
transactionalEmailStageDurationMs,
} from './services/hogflows/transactional-email'
import { decodeInvocationGlobals } from './services/monitoring/hog-invocation-results.service'
import { BASE_REDIS_KEY, HogWatcherState } from './services/monitoring/hog-watcher.service'
import { compileHog } from './templates/compiler'
import { HogFunctionInvocationGlobals, HogFunctionType } from './types'
Expand Down Expand Up @@ -2220,6 +2223,26 @@ describe('CDP API', () => {
tracking: { captureEngagement: true },
}

const FILE_CONTENT = 'c2VjcmV0LWZpbGU='
const attachment = (overrides: Record<string, unknown> = {}) => ({
filename: 'report.pdf',
content: FILE_CONTENT,
contentType: 'application/pdf',
...overrides,
})
const storedAttachments = async (filename: string): Promise<{ job_id: string; content: Buffer }[]> => {
const pool = new Pool({ connectionString: hub.CYCLOTRON_NODE_DATABASE_URL })
try {
return (
await pool.query('SELECT job_id, content FROM cyclotron_email_attachments WHERE filename = $1', [
filename,
])
).rows
} finally {
await pool.end()
}
}

beforeEach(async () => {
produceOutput = jest.spyOn(api['emailStatusService']['outputs'], 'produce')
mockQueueInvocations = jest.fn().mockResolvedValue(undefined)
Expand Down Expand Up @@ -2393,6 +2416,14 @@ describe('CDP API', () => {
['transactional_email/send', { ...body, send: { ...codeSend, cc: ['manager@example.com'] } }],
['transactional_email/send', { ...body, send: { ...codeSend, cc: ['someone-else@example.com'] } }],
],
[
'a changed file under the same name',
['transactional_email/send', { ...body, send: { ...codeSend, attachments: [attachment()] } }],
[
'transactional_email/send',
{ ...body, send: { ...codeSend, attachments: [attachment({ content: 'b3RoZXI=' })] } },
],
],
] as const)(
'rejects the key for a request with %s',
async (_desc, [firstRoute, first], [route, payload]) => {
Expand Down Expand Up @@ -2512,12 +2543,58 @@ describe('CDP API', () => {
'more than 10 cc addresses',
{ ...codeSend, cc: Array.from({ length: 11 }, (_, i) => `copy-${i}@example.com`) },
],
['a file that is not base64', { ...codeSend, attachments: [attachment({ content: `${FILE_CONTENT}!` })] }],
['an empty file', { ...codeSend, attachments: [attachment({ content: '' })] }],
[
'a file too long to check as base64',
{ ...codeSend, attachments: [attachment({ content: 'A'.repeat(4_500_000) })] },
],
[
'a file type mailbox providers block',
{ ...codeSend, attachments: [attachment({ filename: 'setup.EXE' })] },
],
['a file type only SES refuses', { ...codeSend, attachments: [attachment({ filename: 'settings.reg' })] }],
['a file type only Gmail blocks', { ...codeSend, attachments: [attachment({ filename: 'disk.iso' })] }],
[
'a filename with a path separator',
{ ...codeSend, attachments: [attachment({ filename: '../report.pdf' })] },
],
[
'a filename with a line break',
{ ...codeSend, attachments: [attachment({ filename: 'report\r\n.pdf' })] },
],
[
'a content type that is not a MIME type',
{ ...codeSend, attachments: [attachment({ contentType: 'pdf' })] },
],
['a content id in angle brackets', { ...codeSend, attachments: [attachment({ contentId: '<logo>' })] }],
[
'a content id longer than SES accepts',
{ ...codeSend, attachments: [attachment({ contentId: 'x'.repeat(79) })] },
],
[
'a content type longer than SES accepts',
{ ...codeSend, attachments: [attachment({ contentType: `application/${'x'.repeat(67)}` })] },
],
['a file field this version does not know', { ...codeSend, attachments: [attachment({ size: 11 })] }],
['more than 10 files', { ...codeSend, attachments: Array.from({ length: 11 }, () => attachment()) }],
[
'files over 512 KB in total',
{
...codeSend,
attachments: [
attachment({ content: Buffer.alloc(300 * 1024).toString('base64') }),
attachment({ content: `${Buffer.alloc(213 * 1024).toString('base64')}${FILE_CONTENT}` }),
],
},
],
])('refuses a code send with %s', async (_desc, acceptedSend) => {
const invalidBefore = await outcomeCount('invalid_request')

const res = await sendAccepted(hogFlow.id, { ...body, send: acceptedSend })

expect(res.status).toEqual(400)
expect(JSON.stringify(res.body)).not.toContain(FILE_CONTENT.slice(0, 12))
expect(await outcomeCount('invalid_request')).toEqual(invalidBefore + 1)
expect(mockQueueInvocations).not.toHaveBeenCalled()
})
Expand Down Expand Up @@ -2554,6 +2631,65 @@ describe('CDP API', () => {
expect(mockQueueInvocations).not.toHaveBeenCalled()
})

it('stores the files of a code send outside the queued run and its snapshot, and a replay stores none', async () => {
const resultsOutputs = api['invocationResultsService'].invocationResultsRowsService['outputs']
const produceResultRow = jest.spyOn(resultsOutputs, 'produce')
const resultsEnabled = hub.HOG_INVOCATION_RESULTS_ENABLED
hub.HOG_INVOCATION_RESULTS_ENABLED = true
const filename = `report-${new UUIDT().toString()}.pdf`
const keyed = {
...body,
idempotency_key: `key-${new UUIDT().toString()}`,
send: { ...codeSend, attachments: [attachment({ filename })] },
}

try {
const first = await sendAccepted(hogFlow.id, keyed)
const replay = await sendAccepted(hogFlow.id, keyed)

expect(first.status).toEqual(202)
expect(replay.body).toMatchObject({ invocation_id: first.body.invocation_id, replayed: true })
expect(await storedAttachments(filename)).toEqual([
{ job_id: first.body.invocation_id, content: Buffer.from('secret-file') },
])
const [invocation] = mockQueueInvocations.mock.calls[0][0]
expect(invocation.state.send.attachments).toEqual([
{
filename,
contentType: 'application/pdf',
sizeBytes: 11,
sha256: createHash('sha256').update('secret-file').digest('hex'),
},
])
expect(JSON.stringify(invocation)).not.toContain(FILE_CONTENT)
const snapshots = produceResultRow.mock.calls.filter(([output]) => output === 'hog_invocation_results')
expect(snapshots).not.toHaveLength(0)
for (const [, message] of snapshots) {
const row = parseJSON(String(message.value))
const globals = JSON.stringify(await decodeInvocationGlobals(row.invocation_globals))
expect(globals).toContain(filename)
expect(globals).not.toContain(FILE_CONTENT)
}
} finally {
hub.HOG_INVOCATION_RESULTS_ENABLED = resultsEnabled
}
})

it('keeps the stored files when the queue insert fails, as the job may be queued already', async () => {
mockQueueInvocations.mockRejectedValueOnce(new Error('queue down'))
const filename = `report-${new UUIDT().toString()}.pdf`

const res = await sendAccepted(hogFlow.id, {
...body,
send: { ...codeSend, attachments: [attachment({ filename })] },
})

expect(res.status).toEqual(500)
expect(await storedAttachments(filename)).toEqual([
{ job_id: expect.any(String), content: Buffer.from('secret-file') },
])
})

it.each<[string, () => { payload: Record<string, unknown>; verify?: () => void }]>([
[
'the team is over its email quota',
Expand Down
24 changes: 19 additions & 5 deletions nodejs/src/cdp/cdp-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,15 @@ import {
AcceptedSend,
AcceptedSendRequestSchema,
CODE_SEND_TOKEN_CLAIMS,
QueuedSend,
TEMPLATE_SEND_TOKEN_CLAIMS,
TransactionalEmailOutcome,
TransactionalEmailRequest,
TransactionalEmailRequestSchema,
TransactionalEmailStageTimer,
decodeAttachments,
isRecipientFormatIssue,
queuedSend,
transactionalEmailRecipients,
transactionalEmailRequestsTotal,
} from './services/hogflows/transactional-email'
Expand All @@ -71,6 +74,7 @@ import { JobQueue } from './services/job-queue/job-queue.interface'
import { GroupsManagerService } from './services/managers/groups-manager.service'
import { HogFunctionManagerService } from './services/managers/hog-function-manager.service'
import { TeamWorkflowsConfigService } from './services/managers/team-workflows-config.service'
import { EmailAttachmentsService } from './services/messaging/email-attachments.service'
import { EMAIL_QUEUE_PRIORITY, EmailQueuePriorityClass, cdpEmailQueuedTotal } from './services/messaging/email-priority'
import { EmailStatusService } from './services/messaging/email-status.service'
import { EmailSuppressionService } from './services/messaging/email-suppression.service'
Expand Down Expand Up @@ -178,7 +182,8 @@ const ACCEPTED_SEND_FINGERPRINT_MARKER = 'accepted_send'

const transactionalEmailFingerprint = (
hogFlowId: string,
{ to, distinct_id, variables, send }: TransactionalEmailRequest
{ to, distinct_id, variables }: TransactionalEmailRequest,
send: QueuedSend | undefined
): string => {
const templateRequest = [hogFlowId, to, distinct_id, variables]
return IdempotencyKeys.fingerprint(
Expand All @@ -191,7 +196,7 @@ type CallerChosenSender = Partial<NonNullable<AcceptedSend['from']>>
const withSenderTheCallerChose = ({
from,
...send
}: AcceptedSend): Omit<AcceptedSend, 'from'> & { from?: CallerChosenSender } => ({
}: QueuedSend): Omit<QueuedSend, 'from'> & { from?: CallerChosenSender } => ({
...send,
from: senderTheCallerChose(from),
})
Expand Down Expand Up @@ -236,6 +241,7 @@ export class CdpApi {
private transactionalEmailJwt: ScopedServiceJwt
private transactionalEmailIdempotencyKeys: IdempotencyKeys
private emailSuppressionService: EmailSuppressionService
private emailAttachmentsService: EmailAttachmentsService
private emailValidationService: EmailValidationService
private teamWorkflowsConfigService: TeamWorkflowsConfigService
private emailStatusService: EmailStatusService
Expand All @@ -253,6 +259,7 @@ export class CdpApi {
this.hogFlowManager = services.hogFlowManager
this.recipientTokensService = services.recipientTokensService
this.emailSuppressionService = services.emailSuppressionService
this.emailAttachmentsService = services.emailAttachmentsService
this.emailValidationService = services.emailValidationService
this.teamWorkflowsConfigService = services.teamWorkflowsConfigService
this.hogExecutorAsync = services.hogExecutorAsync
Expand Down Expand Up @@ -366,6 +373,7 @@ export class CdpApi {
this.batchExportHogFunctionService.stop(),
this.rerunJobManager?.disconnect() ?? Promise.resolve(),
this.invocationResultsService.stop(),
this.emailAttachmentsService.stop(),
])
}

Expand Down Expand Up @@ -1288,8 +1296,10 @@ export class CdpApi {
})

const invocation = createHogFlowInvocation(triggerGlobals, hogFlow, filterGlobals)
if (send) {
invocation.state.send = send
const files = decodeAttachments(send)
const sendToQueue = send ? queuedSend(send, files) : undefined
if (sendToQueue) {
invocation.state.send = sendToQueue
}
// The run skips the trigger pipeline that counts runs for event-triggered workflows, so the email
// worker counts it instead.
Expand All @@ -1301,7 +1311,7 @@ export class CdpApi {
invocation.queuePriority = EMAIL_QUEUE_PRIORITY[priorityClass]

const idempotencyKeys = this.transactionalEmailIdempotencyKeys
const fingerprint = transactionalEmailFingerprint(hogFlow.id, parsed.data)
const fingerprint = transactionalEmailFingerprint(hogFlow.id, parsed.data, sendToQueue)
if (idempotencyKey) {
// Checked before the quota, so a retry of a send that went out gets its answer even
// when the team has hit its quota since.
Expand Down Expand Up @@ -1341,6 +1351,10 @@ export class CdpApi {
}
return reply(refusal.outcome, refusal.status, { error: refusal.outcome })
}
if (files.length) {
stages.start('files')
await this.emailAttachmentsService.store(invocation.id, team.id, files)
}
stages.start('queue_insert')
this.invocationResultsService.invocationResultsRowsService.queueLifecycleRow(invocation, 'running')
try {
Expand Down
13 changes: 12 additions & 1 deletion nodejs/src/cdp/cdp-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ import { IntegrationManagerService } from './services/managers/integration-manag
import { PersonsManagerService } from './services/managers/persons-manager.service'
import { RecipientsManagerService } from './services/managers/recipients-manager.service'
import { TeamWorkflowsConfigService } from './services/managers/team-workflows-config.service'
import {
EMAIL_ATTACHMENTS_MAX_CONNECTIONS,
EmailAttachmentsService,
} from './services/messaging/email-attachments.service'
import { EmailStatusService } from './services/messaging/email-status.service'
import { EmailSuppressionService } from './services/messaging/email-suppression.service'
import { EmailValidationService } from './services/messaging/email-validation.service'
Expand Down Expand Up @@ -124,6 +128,7 @@ export interface CdpCoreServices {
messageAssetsService: MessageAssetsService
/** Buffers transactional email status rows. `invocationResultsService.flush()` produces them. */
emailStatusService: EmailStatusService
emailAttachmentsService: EmailAttachmentsService
}

export type CdpCoreServicesConfig = Pick<
Expand Down Expand Up @@ -471,6 +476,10 @@ export function createCdpCoreServices(
const teamEmailRateLimiter = deps.emailValidationValkey
? new RateLimiterService(deps.emailValidationValkey, { name: 'team-email' })
: null
const emailAttachmentsService = new EmailAttachmentsService(
config.CYCLOTRON_NODE_DATABASE_URL,
EMAIL_ATTACHMENTS_MAX_CONNECTIONS
)
const emailService = new EmailService(
{
sesAccessKeyId: config.SES_ACCESS_KEY_ID,
Expand All @@ -495,7 +504,8 @@ export function createCdpCoreServices(
workflowsActivationReporter,
messageAssetsService,
workflowEmailRateLimiter,
teamEmailRateLimiter
teamEmailRateLimiter,
emailAttachmentsService
)
const recipientTokensService = new RecipientTokensService(config.ENCRYPTION_SALT_KEYS, config.SITE_URL)
const hogInputsService = new HogInputsService(deps.integrationManager, recipientTokensService, deps.encryptedFields)
Expand Down Expand Up @@ -644,6 +654,7 @@ export function createCdpCoreServices(
recipientTokensService,
outputs,
emailService,
emailAttachmentsService,
messageAssetsService,
emailStatusService,
}
Expand Down
4 changes: 4 additions & 0 deletions nodejs/src/cdp/consumers/cdp-base.consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { HogFunctionManagerService } from '../services/managers/hog-function-man
import { HogFunctionTemplateManagerService } from '../services/managers/hog-function-template-manager.service'
import { PersonsManagerService } from '../services/managers/persons-manager.service'
import { RecipientsManagerService } from '../services/managers/recipients-manager.service'
import { EmailAttachmentsService } from '../services/messaging/email-attachments.service'
import { EmailService } from '../services/messaging/email.service'
import { RecipientPreferencesService } from '../services/messaging/recipient-preferences.service'
import { HogFunctionMonitoringService } from '../services/monitoring/hog-function-monitoring.service'
Expand Down Expand Up @@ -73,6 +74,7 @@ export abstract class CdpConsumerBase<TConfig extends CdpConsumerBaseConfig = Cd
recipientsManager: RecipientsManagerService

emailService: EmailService
emailAttachmentsService: EmailAttachmentsService
hogFunctionMonitoringService: HogFunctionMonitoringService
workflowsActivationReporter: WorkflowsActivationReporter
cdpUsageReporter: CdpUsageReporterService
Expand Down Expand Up @@ -106,6 +108,7 @@ export abstract class CdpConsumerBase<TConfig extends CdpConsumerBaseConfig = Cd
this.recipientPreferencesService = services.recipientPreferencesService
this.hogFlowExecutor = services.hogFlowExecutor
this.emailService = services.emailService
this.emailAttachmentsService = services.emailAttachmentsService
this.hogFunctionMonitoringService = services.hogFunctionMonitoringService
this.workflowsActivationReporter = services.workflowsActivationReporter
this.cdpUsageReporter = services.cdpUsageReporter
Expand Down Expand Up @@ -155,6 +158,7 @@ export abstract class CdpConsumerBase<TConfig extends CdpConsumerBaseConfig = Cd
// Release the per-consumer conversion-watcher pool created in createCdpCoreServices, so
// graceful shutdown frees its cyclotron connections like the other cyclotron pools do.
await this.invocationResultsService.stop()
await this.emailAttachmentsService.stop()
logger.info('👍', `${this.name} - stopped!`)
}

Expand Down
Loading
Loading