diff --git a/apps/mail-bridge/env.ts b/apps/mail-bridge/env.ts index 5f569dac..0fb68633 100644 --- a/apps/mail-bridge/env.ts +++ b/apps/mail-bridge/env.ts @@ -68,6 +68,14 @@ export const env = createEnv({ fwd: z.array(z.string()) }) ), + MAILBRIDGE_TRANSACTIONAL_CREDENTIALS: stringToJSON.pipe( + z.object({ + apiUrl: z.string().url(), + apiKey: z.string().min(1), + sendAsName: z.string().min(1), + sendAsEmail: z.string().email() + }) + ), MAILBRIDGE_POSTAL_SERVERS_DNS_ROOT_URL: z.string().min(1), MAILBRIDGE_LOCAL_MODE: z .string() diff --git a/apps/mail-bridge/package.json b/apps/mail-bridge/package.json index eb2bd1ba..05fdfbbe 100644 --- a/apps/mail-bridge/package.json +++ b/apps/mail-bridge/package.json @@ -8,7 +8,8 @@ "start": "node --import ./.output/tracing.js .output/app.js", "build": "tsup", "check": "tsc --noEmit", - "mock:incoming-mail": "tsx ./scripts/mock-incoming.ts" + "mock:incoming-mail": "tsx ./scripts/mock-incoming.ts", + "stress:email": "tsx ./scripts/email-stress-test.ts" }, "exports": { "./trpc": { diff --git a/apps/mail-bridge/scripts/email-stress-test.ts b/apps/mail-bridge/scripts/email-stress-test.ts new file mode 100644 index 00000000..f052a75d --- /dev/null +++ b/apps/mail-bridge/scripts/email-stress-test.ts @@ -0,0 +1,253 @@ +import { + cancel, + group, + intro, + outro, + select, + text, + log, + spinner +} from '@clack/prompts'; +import { nanoIdToken } from '@u22n/utils/zodSchemas'; +import { env } from '../env'; +import { z } from 'zod'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +type EmailData = { + to: string[]; + cc: string[]; + from: string; + sender: string; + subject: string; + plain_body: string; + html_body: string; + attachments: unknown[]; + headers: Record; +}; + +type PostalResponse = + | { + status: 'success'; + time: number; + flags: unknown; + data: { + message_id: string; + messages: Record< + string, + { + id: number; + token: string; + } + >; + }; + } + | { + status: 'parameter-error'; + time: number; + flags: unknown; + data: { + message: string; + }; + }; + +async function sendEmail(emailData: EmailData): Promise { + const config = env.MAILBRIDGE_TRANSACTIONAL_CREDENTIALS; + const sendMailPostalResponse = (await fetch( + `${config.apiUrl}/api/v1/send/message`, + { + method: 'POST', + headers: { + 'X-Server-API-Key': `${config.apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(emailData) + } + ) + .then((res) => res.json()) + .catch((e: Error) => { + log.error(`🚨 error sending email ${e.message}`); + return { + status: 'parameter-error', + time: Date.now(), + flags: {}, + data: { message_id: 'console', messages: {} } + }; + })) as PostalResponse; + + return sendMailPostalResponse; +} + +const { start, stop } = spinner(); + +const sendIndividualEmails = async ( + email: string, + amount: number, + interval: number, + testIdentifier: string +) => { + for (let i = 0; i < amount; i++) { + const uniqueId = Date.now() + i; + const subject = `Test Email ${i + 1} (ID: ${uniqueId}, testId: ${testIdentifier})`; + const content = `This is a test email (${i + 1} of ${amount}).`; + + const emailData = { + to: [email], + cc: [], + from: `${env.MAILBRIDGE_TRANSACTIONAL_CREDENTIALS.sendAsName} <${env.MAILBRIDGE_TRANSACTIONAL_CREDENTIALS.sendAsEmail}>`, + sender: env.MAILBRIDGE_TRANSACTIONAL_CREDENTIALS.sendAsEmail, + subject, + plain_body: content, + html_body: `

${content}

`, + attachments: [], + headers: {} + }; + + try { + const response = await sendEmail(emailData); + + if (response.status === 'success') { + log.info( + `Email ${i + 1} sent successfully: ${JSON.stringify(response.data)}` + ); + } else { + log.error( + `Error sending email ${i + 1}: ${JSON.stringify(response.data)}` + ); + } + } catch (error) { + log.error( + `Unexpected error sending email ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + + if (i < amount - 1) { + start(`Waiting ${interval} seconds before sending next email...`); + await sleep(interval * 1000); + stop(`Sending Email ${i + 1} of ${amount}`); + } + } +}; + +const sendReplyChain = async ( + email: string, + amount: number, + interval: number, + testIdentifier: string +) => { + let previousMessageId: string | undefined; + + for (let i = 0; i < amount; i++) { + const subject = + i === 0 + ? `Initial Email (Hash: ${testIdentifier})` + : `Re: Initial Email (Hash: ${testIdentifier})`; + const content = `This is ${i === 0 ? 'the initial email' : `a reply (${i} of ${amount - 1})`}.`; + + const emailData: EmailData = { + to: [email], + cc: [], + from: `${env.MAILBRIDGE_TRANSACTIONAL_CREDENTIALS.sendAsName} <${env.MAILBRIDGE_TRANSACTIONAL_CREDENTIALS.sendAsEmail}>`, + sender: env.MAILBRIDGE_TRANSACTIONAL_CREDENTIALS.sendAsEmail, + subject, + plain_body: content, + html_body: `

${content}

`, + attachments: [], + headers: previousMessageId ? { 'In-Reply-To': previousMessageId } : {} + }; + + try { + const response = await sendEmail(emailData); + + if (response.status === 'success') { + log.info( + `Email ${i + 1} sent successfully: ${JSON.stringify(response.data)}` + ); + previousMessageId = response.data.message_id; + } else { + log.error( + `Error sending email ${i + 1}: ${JSON.stringify(response.data)}` + ); + } + } catch (error) { + log.error( + `Unexpected error sending email ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + + if (i < amount - 1) { + start(`Waiting ${interval} seconds before sending next email...`); + await sleep(interval * 1000); + stop(`Sending Email ${i + 1} of ${amount}`); + } + } +}; + +intro('Stress Test Email'); + +const params = await group( + { + email: () => + text({ + message: 'Enter the email address to send to', + validate: (value) => { + if (!z.string().email().safeParse(value).success) { + return 'Email is not valid'; + } + } + }), + amount: () => + text({ + message: 'Enter the amount of emails to send', + validate: (value) => { + if (!z.coerce.number().int().min(1).safeParse(value).success) { + return 'Amount must be a positive integer'; + } + if (Number(value) > 50) { + return 'Amount must be less than 50'; + } + }, + initialValue: '10' + }), + interval: () => + text({ + message: 'Enter the interval between emails in seconds', + validate: (value) => { + if (!z.coerce.number().int().min(1).safeParse(value).success) { + return 'Interval must be a positive integer'; + } + if (Number(value) > 60) { + return 'Interval must be less than 60'; + } + }, + initialValue: '1' + }), + mode: () => + select({ + message: 'Select the mode', + options: [ + { value: 'individual', label: 'Individual' }, + { value: 'reply-chain', label: 'Reply Chain' } + ], + initialValue: 'individual' + }) + }, + { + onCancel: () => { + cancel('Cancelled'); + process.exit(0); + } + } +); + +const sendFunction = + params.mode === 'individual' ? sendIndividualEmails : sendReplyChain; + +await sendFunction( + params.email, + Number(params.amount), + Number(params.interval), + nanoIdToken(6) +); + +outro('Emails sent'); diff --git a/package.json b/package.json index b180b379..8ea4ffdb 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "db:drop": "dotenv -e .env.local -- turbo run db:drop", "start:all:r": "infisical run --env=remote -- turbo run start", "dev:r": "infisical run --env=remote -- turbo run dev", - "dev:spaces": "infisical run --env=spacesremote -- turbo run dev", + "stress:email": "dotenv -e .env.local -- pnpm --dir apps/mail-bridge stress:email", + "stress:email:r": "infisical run --env=remote -- pnpm --dir apps/mail-bridge stress:email", "db:push:r": "infisical run --env=remote -- pnpm --dir packages/database db:push", "db:studio:r": "infisical run --env=remote -- turbo run db:studio", "db:generate:r": "infisical run --env=remote -- turbo run db:generate", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ce8f880..e41f1f1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -766,6 +766,25 @@ importers: specifier: ^3.23.8 version: 3.23.8 + packages/scripts: + dependencies: + '@t3-oss/env-core': + specifier: ^0.11.0 + version: 0.11.0(typescript@5.5.3)(zod@3.23.8) + '@u22n/platform': + specifier: workspace:^ + version: link:../../apps/platform + zod: + specifier: ^3.23.8 + version: 3.23.8 + devDependencies: + '@types/node': + specifier: ^20.14.10 + version: 20.14.13 + typescript: + specifier: 5.5.3 + version: 5.5.3 + packages/tiptap: dependencies: '@radix-ui/react-slot':