diff --git a/.env.example b/.env.example index 4c1c9cce..ba410fab 100644 --- a/.env.example +++ b/.env.example @@ -5,10 +5,12 @@ NUXT_PUBLIC_SITE_URL=http://localhost:3000 JWT_SECRET=your-super-secret-jwt-key-change-this -MAILGUN_API_KEY=your-mailgun-api-key -MAILGUN_DOMAIN=mg.yourdomain.com -# Optional: Use 'api.eu.mailgun.net' for EU region -MAILGUN_HOST= +# SendGrid (transactional email: invites, password resets, verification). +# Sends through the v3 JSON API. The From address in SMTP_FROM must be on a +# domain authenticated in the SendGrid account. +SENDGRID_API_KEY=your-sendgrid-api-key +# Optional API host override; defaults to api.sendgrid.com (use api.eu.sendgrid.com for EU) +SENDGRID_HOST= SMTP_FROM=noreply@yourdomain.com SMTP_FROM_NAME=My App diff --git a/bun.lock b/bun.lock index fe6a8009..72d2acbe 100644 --- a/bun.lock +++ b/bun.lock @@ -38,7 +38,6 @@ "mapbox-gl": "^3.22.0", "markdown-it": "^14.1.1", "nodemailer": "^7.0.6", - "nodemailer-mailgun-transport": "^2.1.5", "nuxt": "^4.4.2", "postgres": "^3.4.7", "tailwindcss": "^4.2.2", @@ -54,7 +53,6 @@ "@types/jsonwebtoken": "^9.0.10", "@types/markdown-it": "^14.1.2", "@types/nodemailer": "^7.0.1", - "@types/nodemailer-mailgun-transport": "^1.4.6", "@types/turndown": "^5.0.6", "eslint": "^10.2.0", "sass": "^1.99.0", @@ -892,8 +890,6 @@ "@types/nodemailer": ["@types/nodemailer@7.0.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g=="], - "@types/nodemailer-mailgun-transport": ["@types/nodemailer-mailgun-transport@1.4.6", "", { "dependencies": { "@types/nodemailer": "*" } }, "sha512-6qhtDo+1ZLtrmmpQN7O9e3NLK5ggnTS2Oca+22SvmhwChNKxDZErecTlF6qTOLnNW/CCcHmDaSmG2MXUeP1w9g=="], - "@types/pbf": ["@types/pbf@3.0.5", "", {}, "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA=="], "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], @@ -1004,7 +1000,7 @@ "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.34", "", { "dependencies": { "@vue/compiler-dom": "3.5.34", "@vue/shared": "3.5.34" } }, "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ=="], - "@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="], + "@vue/devtools-api": ["@vue/devtools-api@8.1.2", "", { "dependencies": { "@vue/devtools-kit": "^8.1.2" } }, "sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg=="], "@vue/devtools-core": ["@vue/devtools-core@8.1.2", "", { "dependencies": { "@vue/devtools-kit": "^8.1.2", "@vue/devtools-shared": "^8.1.2" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-ZGGyaSBP4/+bN2Nd9ZHNYAVDRIzMw1rv2RyXWtyZlo6mQal+IDmTvKY4V+DjAEBhaXt30mHmsgYp1yXJ/2tIWg=="], @@ -1100,8 +1096,6 @@ "bare-url": ["bare-url@2.4.3", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ=="], - "base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.31", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q=="], @@ -1114,8 +1108,6 @@ "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], - "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], @@ -1196,8 +1188,6 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - "consolidate": ["consolidate@0.15.1", "", { "dependencies": { "bluebird": "^3.1.1" } }, "sha512-DW46nrsMJgy9kqAbPt5rKaCr7uFtpo4mSUvLHIUbJEjm0vo+aY5QLwBUq3FK4tRnJr/X0Psc0C4jf/h+HtXSMw=="], - "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -1762,8 +1752,6 @@ "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], - "mailgun.js": ["mailgun.js@8.2.2", "", { "dependencies": { "axios": "^1.3.3", "base-64": "^1.0.0", "url-join": "^4.0.1" } }, "sha512-po/KtofzrTuKhHLenbmliDsVVOFANwcfDFUGnggwnyZJmZz7JgBlV6nzK9o2Fk+OK2SiBmJTK25RbkAj57Hd+Q=="], - "mapbox-gl": ["mapbox-gl@3.24.0", "", { "dependencies": { "@mapbox/mapbox-gl-supported": "^3.0.0", "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.0.6", "@mapbox/unitbezier": "^0.0.1", "@mapbox/vector-tile": "^2.0.4", "@types/geojson": "^7946.0.16", "@types/geojson-vt": "^3.2.5", "@types/pbf": "^3.0.5", "@types/supercluster": "^7.1.3", "cheap-ruler": "^4.0.0", "csscolorparser": "~1.0.3", "earcut": "^3.0.1", "geojson-vt": "^4.0.2", "gl-matrix": "^3.4.4", "kdbush": "^4.0.2", "martinez-polygon-clipping": "^0.8.1", "murmurhash-js": "^1.0.0", "pbf": "^4.0.1", "potpack": "^2.0.0", "quickselect": "^3.0.0", "supercluster": "^8.0.1", "tinyqueue": "^3.0.0" } }, "sha512-R+FdFUB3DnoE5FYASV7lGSiRyMkSblcZ2UEy7b2pt7s5ZbCxFIUPXd0E6iAFd8OdvdA2VtbvZZVylzAZNaurjA=="], "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], @@ -1848,8 +1836,6 @@ "nodemailer": ["nodemailer@7.0.13", "", {}, "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw=="], - "nodemailer-mailgun-transport": ["nodemailer-mailgun-transport@2.1.5", "", { "dependencies": { "consolidate": "^0.15.1", "form-data": "^4.0.0", "mailgun.js": "^8.0.1" } }, "sha512-hF7POkaxFgMvYEd5aHLaQJI2511ld+aQlQi7JH6bGjhjlZ33cIbTB9PimlIrLu5XC3z76Kde6e65OIwL9lOdTA=="], - "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -2354,8 +2340,6 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="], - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "valibot": ["valibot@1.4.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg=="], @@ -2696,7 +2680,7 @@ "vite-plugin-inspect/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], - "vue-router/@vue/devtools-api": ["@vue/devtools-api@8.1.2", "", { "dependencies": { "@vue/devtools-kit": "^8.1.2" } }, "sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg=="], + "vue-i18n/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="], "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -2766,6 +2750,8 @@ "@intlify/vue-i18n-extensions/vue-i18n/@intlify/core-base": ["@intlify/core-base@10.0.8", "", { "dependencies": { "@intlify/message-compiler": "10.0.8", "@intlify/shared": "10.0.8" } }, "sha512-FoHslNWSoHjdUBLy35bpm9PV/0LVI/DSv9L6Km6J2ad8r/mm0VaGg06C40FqlE8u2ADcGUM60lyoU7Myo4WNZQ=="], + "@intlify/vue-i18n-extensions/vue-i18n/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@mapbox/node-pre-gyp/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], diff --git a/nuxt.config.ts b/nuxt.config.ts index 44058c81..f5d98df4 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -245,9 +245,8 @@ export default defineNuxtConfig({ oauthDcrDefaultScopes: ['pages.view', 'pages.write', 'pages.publish'], databaseUrl: process.env.DATABASE_URL || '', jwtSecret: process.env.JWT_SECRET || '', - mailgunApiKey: process.env.MAILGUN_API_KEY || '', - mailgunDomain: process.env.MAILGUN_DOMAIN || '', - mailgunHost: process.env.MAILGUN_HOST || '', + sendgridApiKey: process.env.SENDGRID_API_KEY || '', + sendgridHost: process.env.SENDGRID_HOST || 'api.sendgrid.com', smtpFrom: process.env.SMTP_FROM || '', smtpFromName: process.env.SMTP_FROM_NAME || '', s3Endpoint: process.env.S3_ENDPOINT || '', diff --git a/package.json b/package.json index 81524aa1..f2c9fdfe 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "mapbox-gl": "^3.22.0", "markdown-it": "^14.1.1", "nodemailer": "^7.0.6", - "nodemailer-mailgun-transport": "^2.1.5", "nuxt": "^4.4.2", "postgres": "^3.4.7", "tailwindcss": "^4.2.2", @@ -61,7 +60,6 @@ "@types/jsonwebtoken": "^9.0.10", "@types/markdown-it": "^14.1.2", "@types/nodemailer": "^7.0.1", - "@types/nodemailer-mailgun-transport": "^1.4.6", "@types/turndown": "^5.0.6", "eslint": "^10.2.0", "sass": "^1.99.0", diff --git a/server/utils/email.ts b/server/utils/email.ts index a977a58d..09ab50d6 100644 --- a/server/utils/email.ts +++ b/server/utils/email.ts @@ -1,28 +1,23 @@ import nodemailer from 'nodemailer' import type { Transporter } from 'nodemailer' -import mailgunTransport from 'nodemailer-mailgun-transport' +import { sendViaSendgrid } from './sendgrid' import { renderEmailTemplate, type EmailTemplateData } from './email-templates' const isDevelopment = (process.env.NODE_ENV || 'development') === 'development' +// MailHog transporter, development only. Production sends go through SendGrid. let transporter: Transporter | null = null function getEmailConfig() { try { const config = useRuntimeConfig() return { - mailgunApiKey: config.mailgunApiKey || process.env.MAILGUN_API_KEY, - mailgunDomain: config.mailgunDomain || process.env.MAILGUN_DOMAIN, - mailgunHost: config.mailgunHost || process.env.MAILGUN_HOST, smtpFrom: config.smtpFrom || process.env.SMTP_FROM, smtpFromName: config.smtpFromName || process.env.SMTP_FROM_NAME, appName: config.appName || process.env.APP_NAME } } catch { return { - mailgunApiKey: process.env.MAILGUN_API_KEY, - mailgunDomain: process.env.MAILGUN_DOMAIN, - mailgunHost: process.env.MAILGUN_HOST, smtpFrom: process.env.SMTP_FROM, smtpFromName: process.env.SMTP_FROM_NAME, appName: process.env.APP_NAME @@ -30,39 +25,16 @@ function getEmailConfig() { } } -function getTransporter(): Transporter { +function getDevTransporter(): Transporter { if (transporter) return transporter - const config = getEmailConfig() - - if (isDevelopment) { - console.log('[Email] Using MailHog (development mode)') - transporter = nodemailer.createTransport({ - host: 'localhost', - port: 1025, - secure: false, - tls: { rejectUnauthorized: false } - }) - return transporter - } - - if (!config.mailgunApiKey || !config.mailgunDomain) { - throw new Error('Mailgun configuration incomplete. Set MAILGUN_API_KEY and MAILGUN_DOMAIN.') - } - - console.log('[Email] Using Mailgun HTTP API') - const mailgunOptions: any = { - auth: { - api_key: config.mailgunApiKey, - domain: config.mailgunDomain - } - } - - if (config.mailgunHost) { - mailgunOptions.host = config.mailgunHost - } - - transporter = nodemailer.createTransport(mailgunTransport(mailgunOptions)) + console.log('[Email] Using MailHog (development mode)') + transporter = nodemailer.createTransport({ + host: 'localhost', + port: 1025, + secure: false, + tls: { rejectUnauthorized: false } + }) return transporter } @@ -95,16 +67,31 @@ export async function sendEmail(options: EmailOptions): Promise { fromEmail = fromName ? `${fromName} <${fromAddress}>` : fromAddress } - const mailOptions = { - from: fromEmail, - to: Array.isArray(options.to) ? options.to.join(', ') : options.to, - subject: options.subject, - html: options.html, - text: options.text || options.html.replace(/<[^>]*>/g, '') - } + const text = options.text || options.html.replace(/<[^>]*>/g, '') - const info = await getTransporter().sendMail(mailOptions) - console.log('[Email] Sent successfully:', info.messageId) + let messageId: string | undefined + if (isDevelopment) { + const info = await getDevTransporter().sendMail({ + from: fromEmail, + to: Array.isArray(options.to) ? options.to.join(', ') : options.to, + subject: options.subject, + html: options.html, + text + }) + messageId = info.messageId + } else { + // Recipient arrays go through as-is: SendGrid takes one address object per + // recipient, and a comma-joined string would be read as a single address. + const info = await sendViaSendgrid({ + from: fromEmail, + to: options.to, + subject: options.subject, + html: options.html, + text + }) + messageId = info.messageId + } + console.log('[Email] Sent successfully:', messageId) return true } catch (error) { if (!process.env.VITEST) { diff --git a/server/utils/sendgrid.ts b/server/utils/sendgrid.ts new file mode 100644 index 00000000..eca45d74 --- /dev/null +++ b/server/utils/sendgrid.ts @@ -0,0 +1,108 @@ +/** + * Twilio SendGrid transport for transactional email. Sends via a direct fetch + * to the v3 mail/send JSON API (no SDK); the response's X-Message-Id header is + * the id SendGrid shows in its Activity Feed and echoes in event webhooks. + */ + +export function getSendgridConfig() { + let apiKey = '' + let host = '' + try { + const config = useRuntimeConfig() + apiKey = config.sendgridApiKey + host = config.sendgridHost + } catch { + // Outside the Nitro context (scripts) fall through to process.env + } + return { + apiKey: apiKey || process.env.SENDGRID_API_KEY || '', + host: host || process.env.SENDGRID_HOST || 'api.sendgrid.com' + } +} + +export function isSendgridConfigured(): boolean { + return Boolean(getSendgridConfig().apiKey) +} + +export interface SendgridSendOptions { + from: string // full address, e.g. '"Doxa" ' + to: string | string[] + subject: string + html: string + text?: string + replyTo?: string + headers?: Record + // Per-request cap; default 30s. + timeoutMs?: number +} + +/** Bare lowercase address from a `"Name" ` or plain `a@b` value. */ +function extractEmailAddress(value: string): string | null { + const angle = value.match(/<([^>]+)>/) + const bare = (angle ? angle[1]! : value).trim().toLowerCase() + const match = bare.match(/[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+/) + return match ? match[0] : null +} + +/** Display name before the angle brackets of a `"Name" ` value, if any. */ +function extractDisplayName(value: string): string | null { + const angle = value.match(/^\s*"?([^"<]*?)"?\s*` style address into SendGrid's {email, name} shape. */ +function toAddressObject(value: string): { email: string, name?: string } { + const email = extractEmailAddress(value) || value + const name = extractDisplayName(value) + return name ? { email, name } : { email } +} + +/** + * Send one message via the SendGrid v3 API. Throws on a non-2xx response; + * returns the X-Message-Id SendGrid assigned. + */ +export async function sendViaSendgrid(options: SendgridSendOptions): Promise<{ messageId?: string }> { + const { apiKey, host } = getSendgridConfig() + if (!apiKey) { + throw new Error('SendGrid configuration incomplete. Set SENDGRID_API_KEY.') + } + + const recipients = (Array.isArray(options.to) ? options.to : [options.to]).map(toAddressObject) + + const payload: Record = { + personalizations: [{ to: recipients }], + from: toAddressObject(options.from), + subject: options.subject, + content: [ + { type: 'text/plain', value: options.text || options.html.replace(/<[^>]*>/g, '') }, + { type: 'text/html', value: options.html } + ] + } + if (options.replyTo) payload.reply_to = toAddressObject(options.replyTo) + if (options.headers && Object.keys(options.headers).length) payload.headers = options.headers + + // Bound the request with a manually-cleared AbortController rather than + // AbortSignal.timeout(): under Bun the latter's per-call timer is never + // reclaimed, so each send leaks memory. + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 30000) + try { + const res = await fetch(`https://${host}/v3/mail/send`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload), + signal: controller.signal + }) + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`SendGrid responded ${res.status}: ${body}`) + } + return { messageId: res.headers.get('x-message-id') || undefined } + } finally { + clearTimeout(timeout) + } +}