diff --git a/apps/web/package.json b/apps/web/package.json index 89409f4..fc23208 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -48,6 +48,7 @@ "nanoid": "^5.1.5", "next": "^15.3.1", "next-auth": "^4.24.11", + "nodemailer": "^7.0.3", "pnpm": "^10.9.0", "prisma": "^6.6.0", "query-string": "^9.1.1", @@ -70,6 +71,7 @@ "@types/html-to-text": "^9.0.4", "@types/mime-types": "^2.1.4", "@types/node": "^22.15.2", + "@types/nodemailer": "^6.4.17", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", "@types/ua-parser-js": "^0.7.39", diff --git a/apps/web/src/server/aws/ses.ts b/apps/web/src/server/aws/ses.ts index ab76b9d..0ee5a51 100644 --- a/apps/web/src/server/aws/ses.ts +++ b/apps/web/src/server/aws/ses.ts @@ -12,6 +12,8 @@ import { } from "@aws-sdk/client-sesv2"; import { generateKeyPairSync } from "crypto"; import mime from "mime-types"; +import nodemailer from "nodemailer"; +import { Readable } from "stream"; import { env } from "~/env"; import { EmailContent } from "~/types"; import { nanoid } from "../nanoid"; @@ -110,8 +112,7 @@ export async function sendRawEmail({ replyTo, cc, bcc, - // eslint-disable-next-line no-unused-vars - text, // text is not used directly in raw email but kept for interface consistency + text, html, attachments, region, @@ -132,54 +133,51 @@ export async function sendRawEmail({ inReplyToMessageId?: string; }) { const sesClient = getSesClient(region); - const boundary = `NextPart`; - let rawEmail = `From: ${from}\n`; - rawEmail += `To: ${Array.isArray(to) ? to.join(", ") : to}\n`; - rawEmail += cc && cc.length ? `Cc: ${cc.join(", ")}\n` : ""; - rawEmail += bcc && bcc.length ? `Bcc: ${bcc.join(", ")}\n` : ""; - rawEmail += - replyTo && replyTo.length ? `Reply-To: ${replyTo.join(", ")}\n` : ""; - rawEmail += `Subject: ${subject}\n`; - rawEmail += `MIME-Version: 1.0\n`; - // Add headers - if (unsubUrl) { - rawEmail += `List-Unsubscribe: <${unsubUrl}>\n`; - rawEmail += `List-Unsubscribe-Post: List-Unsubscribe=One-Click\n`; - } - if (isBulk) { - rawEmail += `Precedence: bulk\n`; - } - if (inReplyToMessageId) { - rawEmail += `In-Reply-To: <${inReplyToMessageId}@email.amazonses.com>\n`; - rawEmail += `References: <${inReplyToMessageId}@email.amazonses.com>\n`; - } - rawEmail += `X-Entity-Ref-ID: ${nanoid()}\n`; + const { message: messageStream } = await nodemailer + .createTransport({ streamTransport: true }) + .sendMail({ + from, + to, + subject, + html, + attachments: attachments?.map((attachment) => ({ + filename: attachment.filename, + content: attachment.content, + encoding: "base64", + })), + text, + replyTo, + cc, + bcc, + headers: { + "X-Entity-Ref-ID": nanoid(), + ...(unsubUrl + ? { + "List-Unsubscribe": `<${unsubUrl}>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + } + : {}), + ...(isBulk ? { Precedence: "bulk" } : {}), + ...(inReplyToMessageId + ? { + "In-Reply-To": `<${inReplyToMessageId}@email.amazonses.com>`, + References: `<${inReplyToMessageId}@email.amazonses.com>`, + } + : {}), + }, + }); - rawEmail += `Content-Type: multipart/mixed; boundary="${boundary}"\n\n`; - rawEmail += `--${boundary}\n`; - rawEmail += `Content-Type: text/html; charset="UTF-8"\n\n`; - rawEmail += `${html}\n\n`; - - if (attachments && attachments.length > 0) { - for (const attachment of attachments) { - const content = attachment.content; // Assumes content is base64 - const mimeType = - mime.lookup(attachment.filename) || "application/octet-stream"; - rawEmail += `--${boundary}\n`; - rawEmail += `Content-Type: ${mimeType}; name="${attachment.filename}"\n`; - rawEmail += `Content-Disposition: attachment; filename="${attachment.filename}"\n`; - rawEmail += `Content-Transfer-Encoding: base64\n\n`; - rawEmail += `${content}\n\n`; - } + const chunks = []; + for await (const chunk of messageStream) { + chunks.push(chunk); } - - rawEmail += `--${boundary}--`; + const finalMessageData = Buffer.concat(chunks); const command = new SendEmailCommand({ Content: { Raw: { - Data: Buffer.from(rawEmail), + Data: finalMessageData, }, }, ConfigurationSetName: configurationSetName, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3d8b94..d5600c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,7 +130,7 @@ importers: dependencies: '@auth/prisma-adapter': specifier: ^2.9.0 - version: 2.9.0(@prisma/client@6.6.0) + version: 2.9.0(@prisma/client@6.6.0)(nodemailer@7.0.3) '@aws-sdk/client-s3': specifier: ^3.797.0 version: 3.797.0 @@ -220,7 +220,10 @@ importers: version: 15.3.1(react-dom@19.1.0)(react@19.1.0) next-auth: specifier: ^4.24.11 - version: 4.24.11(next@15.3.1)(react-dom@19.1.0)(react@19.1.0) + version: 4.24.11(next@15.3.1)(nodemailer@7.0.3)(react-dom@19.1.0)(react@19.1.0) + nodemailer: + specifier: ^7.0.3 + version: 7.0.3 pnpm: specifier: ^10.9.0 version: 10.9.0 @@ -282,6 +285,9 @@ importers: '@types/node': specifier: ^22.15.2 version: 22.15.2 + '@types/nodemailer': + specifier: ^6.4.17 + version: 6.4.17 '@types/react': specifier: ^19.1.2 version: 19.1.2 @@ -729,7 +735,7 @@ packages: '@types/json-schema': 7.0.15 dev: true - /@auth/core@0.39.0: + /@auth/core@0.39.0(nodemailer@7.0.3): resolution: {integrity: sha512-jusviw/sUSfAh6S/wjY5tRmJOq0Itd3ImF+c/b4HB9DfmfChtcfVJTNJeqCeExeCG8oh4PBKRsMQJsn2W6NhFQ==} peerDependencies: '@simplewebauthn/browser': ^9.0.1 @@ -745,17 +751,18 @@ packages: dependencies: '@panva/hkdf': 1.2.1 jose: 6.0.10 + nodemailer: 7.0.3 oauth4webapi: 3.5.0 preact: 10.24.3 preact-render-to-string: 6.5.11(preact@10.24.3) dev: false - /@auth/prisma-adapter@2.9.0(@prisma/client@6.6.0): + /@auth/prisma-adapter@2.9.0(@prisma/client@6.6.0)(nodemailer@7.0.3): resolution: {integrity: sha512-Vo/o3YJOa6x2gklQ+XE/JfUf8+CVSDci/nvlOc6psiIiUGigqnQpap2DAxD8brNNYGYKOJ4OAiSt6kQyk3WP3g==} peerDependencies: '@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5 || >=6' dependencies: - '@auth/core': 0.39.0 + '@auth/core': 0.39.0(nodemailer@7.0.3) '@prisma/client': 6.6.0(prisma@6.6.0)(typescript@5.8.3) transitivePeerDependencies: - '@simplewebauthn/browser' @@ -13215,7 +13222,7 @@ packages: engines: {node: '>= 0.4.0'} dev: true - /next-auth@4.24.11(next@15.3.1)(react-dom@19.1.0)(react@19.1.0): + /next-auth@4.24.11(next@15.3.1)(nodemailer@7.0.3)(react-dom@19.1.0)(react@19.1.0): resolution: {integrity: sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==} peerDependencies: '@auth/core': 0.34.2 @@ -13234,6 +13241,7 @@ packages: cookie: 0.7.2 jose: 4.15.9 next: 15.3.1(react-dom@19.1.0)(react@19.1.0) + nodemailer: 7.0.3 oauth: 0.9.15 openid-client: 5.7.1 preact: 10.26.5 @@ -13397,6 +13405,11 @@ packages: engines: {node: '>=6.0.0'} dev: false + /nodemailer@7.0.3: + resolution: {integrity: sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==} + engines: {node: '>=6.0.0'} + dev: false + /nopt@7.2.1: resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}