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:
parent
f9d53d5524
commit
4dfee08d13
6 changed files with 154 additions and 74 deletions
10
.env.example
10
.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
|
||||
|
||||
|
|
|
|||
22
bun.lock
22
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=="],
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
108
server/utils/sendgrid.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue