Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
107 commits
Select commit Hold shift + click to select a range
0259cd5
feat(cdp): send transactional email files by reference
Silthus Nov 9, 2026
089551c
Merge layer 5 (cea40ca9957) into feat/node-store-send-references
Silthus Nov 9, 2026
139a7b4
style(cdp): wrap the message estimate line
Silthus Nov 9, 2026
1d3e13a
Merge layer 5 (d658072d4a1) into feat/node-store-send-references
Silthus Nov 9, 2026
941f437
test(cdp): accept a send whose email step cannot render at accept
Silthus Nov 9, 2026
9048e0e
fix(cdp): size a send with files by the message it becomes
Silthus Nov 9, 2026
fbdf6d0
fix(cdp): refuse sends SES would reject, and answer 503 before Django…
Silthus Nov 9, 2026
2bbb45e
fix(cdp): refuse attachment types SES does not send at accept
Silthus Nov 9, 2026
3e759d6
Merge layer 5 (7c52ef6cc29) into feat/node-store-send-references
Silthus Nov 9, 2026
08e2006
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 9, 2026
be2d8a1
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 9, 2026
5bad40f
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 9, 2026
a0e1650
fix(cdp): stop send verification at the attachment deadline
Silthus Nov 9, 2026
1284fdd
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 9, 2026
28cbbc3
chore(cdp): hold the verification deadline in the 503 table instead o…
Silthus Nov 9, 2026
937fa88
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 9, 2026
bd8c4ff
fix(cdp): refuse sends with files that can wait, and size them as sent
Silthus Nov 9, 2026
85e5fa6
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 9, 2026
b518715
fix(cdp): refuse files when any wait comes before a later email
Silthus Nov 9, 2026
a3be2f9
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 9, 2026
93a757c
fix(cdp): size every email step with a linear bound at accept
Silthus Nov 9, 2026
7ce8526
fix(cdp): count escaped html bytes without building the string
Silthus Nov 9, 2026
ddf6344
fix(cdp): refuse files when a step sets variables before an email
Silthus Nov 9, 2026
04985d9
fix(cdp): share one render deadline across email steps at accept
Silthus Nov 9, 2026
174bd6b
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 9, 2026
e10cbf7
fix(cdp): accept files only for the trigger, email and exit shape
Silthus Nov 9, 2026
ec4641c
fix(cdp): size a send with files with the person the worker renders
Silthus Nov 9, 2026
201685d
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 9, 2026
5a499cb
fix(cdp): stop sizing a send once its verification deadline passes
Silthus Nov 9, 2026
19a8b52
fix(cdp): size a send's email step with the groups the worker renders…
Silthus Nov 9, 2026
3cd7c12
fix(cdp): stop building a send's email step once its deadline passes
Silthus Nov 9, 2026
c97d12f
chore(cdp): load the template before holding it in the deadline test
Silthus Nov 9, 2026
e619e2d
fix(cdp): replay a queued send with files before checking its workflow
Silthus Nov 9, 2026
a39a500
fix(cdp): count a send's subject in the size of a message with files
Silthus Nov 9, 2026
addf6ab
fix(cdp): refuse files to a workflow whose sending rate limit can hol…
Silthus Nov 10, 2026
44f9daf
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
f776d70
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
a5a18e8
chore(cdp): test the send route with files through what a caller sees
Silthus Nov 10, 2026
a6d486d
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
c3ad4e4
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
0cc9ce9
chore(cdp): write an older pod's key record without reading the new f…
Silthus Nov 10, 2026
9f02857
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
8f518d5
chore(cdp): record the older pod's key through the send route
Silthus Nov 10, 2026
31a3c23
chore(cdp): drop the test that fakes an older pod's key record
Silthus Nov 10, 2026
b04afd7
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
e5e91a5
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
90b1258
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
137534a
fix(cdp): stop input rendering of a send once its deadline passed
Silthus Nov 10, 2026
f397a02
fix(cdp): check the deadline before each input and right before the v…
Silthus Nov 10, 2026
eac3bd5
chore(cdp): bound the time to size unclosed anchors instead of spying…
Silthus Nov 10, 2026
af02fa8
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
bdc97f5
fix(cdp): start no hog render once the clock passes the send deadline
Silthus Nov 10, 2026
82cc546
fix(cdp): stop a hog render at the send deadline, not its own timeout
Silthus Nov 10, 2026
cfe871d
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
e1ddfc1
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
8b9d864
fix(cdp): refuse control characters in attachment fields and log no r…
Silthus Nov 10, 2026
109fde6
fix(cdp): start no queued hog leaf once its deadline has passed
Silthus Nov 10, 2026
1e85506
chore(cdp): observe a skipped hog leaf by the time its render takes
Silthus Nov 10, 2026
0e95bc8
fix(cdp): let the deadline, not the vm, end a hog render it cuts short
Silthus Nov 10, 2026
512b8d2
fix(cdp): stop the utm renders of a send with files at the deadline
Silthus Nov 10, 2026
5c01590
fix(cdp): stop liquid inputs at the acceptance deadline
Silthus Nov 10, 2026
8b8bda2
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
8e24b8c
chore(cdp): check the deadline cap on a controlled clock
Silthus Nov 10, 2026
8b1c311
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
4233cb1
fix(cdp): accept files only for a transactional email step
Silthus Nov 10, 2026
4ca0bb9
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
0cfad26
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
ba035e9
chore(cdp): import the input type from the schema that exports it
Silthus Nov 10, 2026
d48ce26
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
1a76c68
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
4301037
fix(cdp): count encoded utm bytes without building the query
Silthus Nov 10, 2026
89d04e3
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
9e0b959
fix(cdp): refuse long utm values before parsing them at accept
Silthus Nov 10, 2026
f835f20
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
b5ec43b
fix(cdp): refuse email bodies with too many liquid tags before parsin…
Silthus Nov 10, 2026
08855ef
chore(cdp): drop node:net imports the merged email worker tests no lo…
Silthus Nov 10, 2026
c494b99
fix(cdp): count liquid tags in every rendered field of the email step…
Silthus Nov 10, 2026
cc6ef44
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
bb31cc8
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
435b169
chore(cdp): drop the comments this layer added to production code
Silthus Nov 10, 2026
9731ed8
chore(cdp): check emitted log lines and give timing bounds room
Silthus Nov 10, 2026
29d7d4c
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
c6f7cbe
chore(cdp): drop the sizing test that timed the runner
Silthus Nov 10, 2026
90acd88
chore(cdp): assert a hog input past its deadline never starts, not ho…
Silthus Nov 10, 2026
6c8d6fe
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 10, 2026
303b9df
chore(cdp): assert only the deadline error when a waiting hog input p…
Silthus Nov 10, 2026
ac8e7fc
chore(cdp): assert a hog input past its deadline never reads its event
Silthus Nov 10, 2026
17830e9
chore(cdp): assert the deadline outcome of a hog input, and drop test…
Silthus Nov 10, 2026
e04eab3
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 11, 2026
05493d5
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 11, 2026
0d573f8
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 11, 2026
bf8f5dc
Merge branch 'feat/node-store-upload-route' into feat/node-store-send…
Silthus Nov 11, 2026
9913d71
Merge branch 'feat/node-store-render-deadline' into feat/node-store-s…
Silthus Nov 11, 2026
f7d25d2
Merge branch 'feat/node-store-render-deadline' into feat/node-store-s…
Silthus Nov 11, 2026
0349fe6
Merge branch 'feat/node-store-render-deadline' into feat/node-store-s…
Silthus Nov 11, 2026
3da6f5e
chore(cdp): merge the semaphore deadline fix from the layer below
Silthus Nov 11, 2026
b2f3449
chore(cdp): merge the constant-time semaphore removal from the layer …
Silthus Nov 11, 2026
b2c66b9
chore(cdp): merge the serial semaphore bench from the layer below
Silthus Nov 11, 2026
e9dc768
chore(cdp): merge the dev-only semaphore bench from the layer below
Silthus Nov 11, 2026
353e892
chore(cdp): merge the shared abort listener from the layer below
Silthus Nov 11, 2026
6be27df
chore(cdp): merge the shared-deadline queue test from the layer below
Silthus Nov 11, 2026
752fb88
Merge branch 'feat/node-store-render-deadline' into feat/node-store-s…
Silthus Nov 11, 2026
6131192
fix(cdp): count each statement in a liquid block toward the tag limit
Silthus Nov 11, 2026
65c95f5
fix(cdp): count liquid block statements on the template liquid parses
Silthus Nov 11, 2026
67b7f91
fix(cdp): read a liquid block opener the way liquid reads blanks
Silthus Nov 11, 2026
2309d12
Merge branch 'feat/node-store-render-deadline' into feat/node-store-s…
Silthus Nov 11, 2026
9df7d06
Merge branch 'feat/node-store-render-deadline' into feat/node-store-s…
Silthus Nov 11, 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
170 changes: 163 additions & 7 deletions nodejs/src/cdp/_tests/email-delivery-fakes.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { SESv2Client } from '@aws-sdk/client-sesv2'
import { createHash, randomBytes, randomUUID } from 'node:crypto'
import { IncomingMessage, Server, ServerResponse, createServer, request as httpRequest } from 'node:http'
import { AddressInfo } from 'node:net'
import { AddressInfo, Socket, connect, createServer as createTcpServer } from 'node:net'

import { FakeSes, FakeSesAttachment } from '~/cdp/_tests/fake-ses'
import { getDefaultCdpConfig } from '~/cdp/config'
import { EmailAttachmentMetadata } from '~/cdp/schema/cyclotron'
import { CyclotronJobQueuePostgresV2 } from '~/cdp/services/job-queue/job-queue-postgres-v2'
import { AttachmentStore, buildAttachmentStore } from '~/cdp/services/messaging/attachment-store'
import { CyclotronJobInvocation, CyclotronJobInvocationResult, CyclotronJobQueueKind } from '~/cdp/types'

export const attachmentStoreConfig = {
...getDefaultCdpConfig(),
Expand All @@ -17,9 +19,9 @@ export const attachmentStore = buildAttachmentStore(attachmentStoreConfig)!
export type StoredFile = { bytes: Buffer; metadata: EmailAttachmentMetadata }

export const attachmentFile = (
ref: Omit<Partial<EmailAttachmentMetadata>, 'sha256' | 'sizeBytes'> = {}
ref: Omit<Partial<EmailAttachmentMetadata>, 'sha256' | 'sizeBytes'> = {},
bytes: Buffer = Buffer.from(`%PDF-1.4 ${randomBytes(16).toString('hex')}`)
): StoredFile => {
const bytes = Buffer.from(`%PDF-1.4 ${randomBytes(16).toString('hex')}`)
return {
bytes,
metadata: {
Expand All @@ -33,9 +35,10 @@ export const attachmentFile = (

export const uploadAttachment = async (
teamId: number,
ref: Omit<Partial<EmailAttachmentMetadata>, 'sha256' | 'sizeBytes'>
ref: Omit<Partial<EmailAttachmentMetadata>, 'sha256' | 'sizeBytes'>,
bytes?: Buffer
): Promise<StoredFile> => {
const file = attachmentFile(ref)
const file = attachmentFile(ref, bytes)
const [slot] = await attachmentStore.prepareUploads(teamId, [
{ sha256: file.metadata.sha256, size: file.bytes.length },
])
Expand Down Expand Up @@ -65,10 +68,12 @@ export const deliveredTo = (ses: FakeSes, address: string): DeliveredEmail[] =>
.filter((message) => message.to.some((to) => to.includes(address)))
.map(({ to, attachments }) => ({ to, attachments }))

export type BucketAnswer = 'pass' | 'refuse' | 'hold' | 'holdFilesItHas' | 'stallFileBodies'
export type BucketAnswer = 'pass' | 'refuse' | 'hold' | 'holdCopies' | 'holdFilesItHas' | 'stallFileBodies'

export class BucketProxy {
answer: BucketAnswer = 'pass'
lastModifiedAgeMs = 0
requests = 0
abandonedRequests = 0
downloads = 0
endpoint = ''
Expand Down Expand Up @@ -112,6 +117,7 @@ export class BucketProxy {
this.abandonedRequests++
}
})
this.requests++
if (request.method === 'GET') {
this.downloads++
}
Expand All @@ -126,7 +132,7 @@ export class BucketProxy {
request.socket.destroy()
return
}
if (this.answer === 'hold') {
if (this.answer === 'hold' || (this.answer === 'holdCopies' && request.headers['x-amz-copy-source'])) {
this.held.push(() => this.answerRequest(request, response))
return
}
Expand All @@ -146,6 +152,11 @@ export class BucketProxy {
answer.resume()
return
}
if (request.method === 'HEAD' && this.lastModifiedAgeMs && answer.headers['last-modified']) {
answer.headers['last-modified'] = new Date(
Date.parse(answer.headers['last-modified']) - this.lastModifiedAgeMs
).toUTCString()
}
response.writeHead(answer.statusCode!, answer.headers)
if (fileItHas && this.answer === 'stallFileBodies') {
answer.once('data', (chunk: Buffer) => {
Expand Down Expand Up @@ -173,3 +184,148 @@ export const sesClientFor = (
credentials: { accessKeyId: 'fake-ses', secretAccessKey: 'fake-ses' },
...overrides,
})

export class EmailQueueOfOneTest extends CyclotronJobQueuePostgresV2 {
constructor(
private readonly queueName: string,
config: ConstructorParameters<typeof CyclotronJobQueuePostgresV2>[1]
) {
super(10, config)
}

public override startAsConsumer(
_queue: CyclotronJobQueueKind,
consumeBatch: (invocations: CyclotronJobInvocation[]) => Promise<{ backgroundTask: Promise<any> }>
): Promise<void> {
return super.startAsConsumer(this.queueName as CyclotronJobQueueKind, (invocations) =>
consumeBatch(invocations.map((invocation) => ({ ...invocation, queue: 'email' })))
)
}

public override queueInvocations(invocations: CyclotronJobInvocation[]): Promise<void> {
return super.queueInvocations(invocations.map((invocation) => this.onOwnQueue(invocation)))
}

public override queueInvocationResults(results: CyclotronJobInvocationResult[]): Promise<void> {
return super.queueInvocationResults(
results.map((result) => ({ ...result, invocation: this.onOwnQueue(result.invocation) }))
)
}

private onOwnQueue(invocation: CyclotronJobInvocation): CyclotronJobInvocation {
const queue = invocation.queue === 'email' ? this.queueName : `${this.queueName}/${invocation.queue}`
return { ...invocation, queue: queue as CyclotronJobQueueKind }
}
}

export type HeldQuery = { release: () => void; drop: () => void }

const SSL_REQUEST = 80877103
const GSS_ENCRYPTION_REQUEST = 80877104
const ENDS_A_REQUEST = new Set(['S', 'Q', 'X', 'p', 'H', 'c', 'f'])

export class PostgresProxy {
url = ''
private readonly waiting: { matches: (request: string) => boolean; caught: (held: HeldQuery) => void }[] = []
private readonly holding = new Set<HeldQuery>()
private readonly sockets = new Set<Socket>()
private readonly server = createTcpServer((client) => this.forward(client))

static async start(upstream: URL): Promise<PostgresProxy> {
const proxy = new PostgresProxy(upstream)
await new Promise<void>((resolve) => proxy.server.listen(0, '127.0.0.1', resolve))
const url = new URL(upstream)
url.host = `127.0.0.1:${(proxy.server.address() as AddressInfo).port}`
proxy.url = url.toString()
return proxy
}

private constructor(private readonly upstream: URL) {}

hold(matches: (request: string) => boolean): Promise<HeldQuery> {
return new Promise((caught) => this.waiting.push({ matches, caught }))
}

releaseAll(): void {
this.waiting.splice(0)
for (const held of this.holding) {
held.release()
}
}

async close(): Promise<void> {
for (const socket of this.sockets) {
socket.destroy()
}
await new Promise((resolve) => this.server.close(resolve))
}

private forward(client: Socket): void {
const upstream = connect(Number(this.upstream.port || 5432), this.upstream.hostname)
for (const socket of [client, upstream]) {
this.sockets.add(socket)
socket.on('error', () => undefined)
socket.on('close', () => {
client.destroy()
upstream.destroy()
})
}
upstream.pipe(client)

let unread = Buffer.alloc(0)
let request: Buffer[] = []
let startedUp = false
let paused = false
const forwardRequests = (): void => {
while (!paused) {
const typeBytes = startedUp ? 1 : 0
if (unread.length < typeBytes + 4) {
return
}
const length = typeBytes + unread.readInt32BE(typeBytes)
if (unread.length < length) {
return
}
const message = unread.subarray(0, length)
unread = unread.subarray(length)
if (!startedUp) {
const code = message.readInt32BE(4)
startedUp = code !== SSL_REQUEST && code !== GSS_ENCRYPTION_REQUEST
upstream.write(message)
continue
}
request.push(message)
if (!ENDS_A_REQUEST.has(String.fromCharCode(message[0]))) {
continue
}
const whole = Buffer.concat(request)
request = []
const index = this.waiting.findIndex(({ matches }) => matches(whole.toString('latin1')))
if (index === -1) {
upstream.write(whole)
continue
}
const [{ caught }] = this.waiting.splice(index, 1)
paused = true
const held: HeldQuery = {
release: () => {
this.holding.delete(held)
upstream.write(whole)
paused = false
forwardRequests()
},
drop: () => {
this.holding.delete(held)
client.destroy()
},
}
this.holding.add(held)
caught(held)
}
}
client.on('data', (chunk: Buffer) => {
unread = Buffer.concat([unread, chunk])
forwardRequests()
})
}
}
Loading
Loading