replace mailgun with sendgrid for transactional email

- send through the sendgrid v3 json api via direct fetch with a
  manually cleared abort timeout; mailhog stays for development
- drop nodemailer-mailgun-transport and its types
- swap MAILGUN_* runtime config and env example for SENDGRID_API_KEY
  and optional SENDGRID_HOST
This commit is contained in:
corsacca 2026-09-15 11:16:44 +01:00
parent f9d53d5524
commit 4dfee08d13
6 changed files with 154 additions and 74 deletions

View file

@ -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

View file

@ -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=="],

View file

@ -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 || '',

View file

@ -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",

View file

@ -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<boolean> {
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) {

108
server/utils/sendgrid.ts Normal file
View file

@ -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" <noreply@mail.doxa.life>'
to: string | string[]
subject: string
html: string
text?: string
replyTo?: string
headers?: Record<string, string>
// Per-request cap; default 30s.
timeoutMs?: number
}
/** Bare lowercase address from a `"Name" <a@b>` 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" <a@b>` value, if any. */
function extractDisplayName(value: string): string | null {
const angle = value.match(/^\s*"?([^"<]*?)"?\s*</)
const name = angle?.[1]?.trim()
return name || null
}
/** Split a `"Name" <a@b>` 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<string, unknown> = {
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)
}
}