import nodemailer from 'nodemailer' import { Resend } from 'resend' import { ServerClient as PostmarkClient } from 'postmark' import Mailgun from 'mailgun.js' import FormData from 'form-data' import sgMail, { type MailDataRequired } from '@sendgrid/mail' import { SESClient, SendRawEmailCommand } from '@aws-sdk/client-ses' import { asLegacyMap, channelProvider, channelSettings, legacyProviderNamed, } from '@/features/integrations/Lib/messaging' import { messagingProvider } from '@/integrations/messaging/catalog' import { db } from './db' import { SYSTEM_SETTING_KEYS } from '@/features/admin/Schema/systemSettingsSchema' import { ORG_EMAIL_KEYS } from '@/features/email/Schema/emailSettingsSchema' export type EmailProvider = 'smtp' | 'resend' | 'postmark' | 'mailgun' | 'sendgrid' | 'ses' export interface SendMailOptions { from: string to: string /** * Where a reply should go when it is not the sender. A support request goes * out from the platform address, so without this the administrator's reply * button addresses the platform itself rather than the person asking. */ replyTo?: string subject: string html: string /** * The plain-text half of the mail. A message with no text part scores worse * with spam filters, and some people read mail as text on purpose. Every * provider below takes one; the document mails generate theirs from the * same spec the HTML comes from, so the two cannot disagree. */ text?: string attachments?: { filename: string content: Buffer }[] } // ─── Settings map helper ──────────────────────────────────────────────────── type SettingsMap = Map async function getSystemSettings(keys: string[]): Promise { const rows = await db.systemSetting.findMany({ where: { key: { in: keys } }, }) return new Map(rows.map((r) => [r.key, r.value])) } // ─── System-level helpers (read from SystemSetting + env) ─────────────────── async function getEmailProvider(): Promise { const setting = await db.systemSetting.findUnique({ where: { key: SYSTEM_SETTING_KEYS.EMAIL_PROVIDER }, }) const value = setting?.value if ( value === 'resend' || value === 'postmark' || value === 'mailgun' || value === 'sendgrid' || value === 'ses' ) { return value } return 'smtp' } export async function getFromAddress(): Promise { const provider = await getEmailProvider() const keyMap: Record = { smtp: { email: SYSTEM_SETTING_KEYS.SMTP_FROM_EMAIL, name: SYSTEM_SETTING_KEYS.SMTP_FROM_NAME, }, resend: { email: SYSTEM_SETTING_KEYS.RESEND_FROM_EMAIL, name: SYSTEM_SETTING_KEYS.RESEND_FROM_NAME, }, postmark: { email: SYSTEM_SETTING_KEYS.POSTMARK_FROM_EMAIL, name: SYSTEM_SETTING_KEYS.POSTMARK_FROM_NAME, }, mailgun: { email: SYSTEM_SETTING_KEYS.MAILGUN_FROM_EMAIL, name: SYSTEM_SETTING_KEYS.MAILGUN_FROM_NAME, }, sendgrid: { email: SYSTEM_SETTING_KEYS.SENDGRID_FROM_EMAIL, name: SYSTEM_SETTING_KEYS.SENDGRID_FROM_NAME, }, ses: { email: SYSTEM_SETTING_KEYS.SES_FROM_EMAIL, name: SYSTEM_SETTING_KEYS.SES_FROM_NAME, }, } const keys = keyMap[provider] const rows = await db.systemSetting.findMany({ where: { key: { in: [keys.email, keys.name] } }, }) const map = new Map(rows.map((r) => [r.key, r.value])) const fromEmail = map.get(keys.email) || process.env.SMTP_FROM_EMAIL || 'noreply@example.com' const fromName = map.get(keys.name) || 'Torqvoice' return `${fromName} <${fromEmail}>` } // ─── SMTP ────────────────────────────────────────────────────────────────── interface SmtpConfig { host: string port: number user?: string pass?: string secure: boolean rejectUnauthorized: boolean requireTls: boolean } function buildSmtpConfig( settings: SettingsMap, keys: { host: string port: string user: string pass: string secure: string rejectUnauthorized: string requireTls: string }, useEnvFallback: boolean ): SmtpConfig { const host = settings.get(keys.host) || (useEnvFallback ? process.env.SMTP_HOST : undefined) const port = Number(settings.get(keys.port)) || (useEnvFallback ? Number(process.env.SMTP_PORT) : 0) || 587 const user = settings.get(keys.user) || (useEnvFallback ? process.env.SMTP_USER : undefined) const pass = settings.get(keys.pass) || (useEnvFallback ? process.env.SMTP_PASS : undefined) const secureSetting = settings.get(keys.secure) const secure = secureSetting !== undefined ? secureSetting === 'true' : useEnvFallback ? process.env.SMTP_SECURE === 'true' : false const rejectSetting = settings.get(keys.rejectUnauthorized) const rejectUnauthorized = rejectSetting !== undefined ? rejectSetting !== 'false' : useEnvFallback ? process.env.SMTP_REJECT_UNAUTHORIZED !== 'false' : true const requireTlsSetting = settings.get(keys.requireTls) const requireTls = requireTlsSetting === 'true' if (!host) { throw new Error('SMTP is not configured. Configure SMTP in your email settings.') } return { host, port, user, pass, secure, rejectUnauthorized, requireTls } } function createSmtpTransporter(config: SmtpConfig) { return nodemailer.createTransport({ host: config.host, port: config.port, secure: config.secure, auth: config.user ? { user: config.user, pass: config.pass, } : undefined, tls: { rejectUnauthorized: config.rejectUnauthorized, }, requireTLS: config.requireTls, }) } async function sendViaSmtpWithSettings( options: SendMailOptions, settings: SettingsMap, keys: { host: string port: string user: string pass: string secure: string rejectUnauthorized: string requireTls: string }, useEnvFallback: boolean ) { const config = buildSmtpConfig(settings, keys, useEnvFallback) const transporter = createSmtpTransporter(config) await transporter.sendMail({ from: options.from, to: options.to, replyTo: options.replyTo, subject: options.subject, html: options.html, text: options.text, attachments: options.attachments?.map((a) => ({ filename: a.filename, content: a.content, })), }) } async function sendViaSmtp(options: SendMailOptions) { const keys = [ SYSTEM_SETTING_KEYS.SMTP_HOST, SYSTEM_SETTING_KEYS.SMTP_PORT, SYSTEM_SETTING_KEYS.SMTP_USER, SYSTEM_SETTING_KEYS.SMTP_PASS, SYSTEM_SETTING_KEYS.SMTP_SECURE, SYSTEM_SETTING_KEYS.SMTP_REJECT_UNAUTHORIZED, SYSTEM_SETTING_KEYS.SMTP_REQUIRE_TLS, ] const settings = await getSystemSettings(keys) await sendViaSmtpWithSettings( options, settings, { host: SYSTEM_SETTING_KEYS.SMTP_HOST, port: SYSTEM_SETTING_KEYS.SMTP_PORT, user: SYSTEM_SETTING_KEYS.SMTP_USER, pass: SYSTEM_SETTING_KEYS.SMTP_PASS, secure: SYSTEM_SETTING_KEYS.SMTP_SECURE, rejectUnauthorized: SYSTEM_SETTING_KEYS.SMTP_REJECT_UNAUTHORIZED, requireTls: SYSTEM_SETTING_KEYS.SMTP_REQUIRE_TLS, }, true ) } // ─── Resend ──────────────────────────────────────────────────────────────── async function sendViaResendWithSettings( options: SendMailOptions, settings: SettingsMap, apiKeyField: string ) { const apiKey = settings.get(apiKeyField) if (!apiKey) { throw new Error('Resend is not configured. Add your Resend API key.') } const resend = new Resend(apiKey) await resend.emails.send({ from: options.from, to: options.to, replyTo: options.replyTo, subject: options.subject, html: options.html, text: options.text, attachments: options.attachments?.map((a) => ({ filename: a.filename, content: a.content, })), }) } async function sendViaResend(options: SendMailOptions) { const settings = await getSystemSettings([SYSTEM_SETTING_KEYS.RESEND_API_KEY]) await sendViaResendWithSettings(options, settings, SYSTEM_SETTING_KEYS.RESEND_API_KEY) } // ─── Postmark ────────────────────────────────────────────────────────────── async function sendViaPostmarkWithSettings( options: SendMailOptions, settings: SettingsMap, apiKeyField: string ) { const apiKey = settings.get(apiKeyField) if (!apiKey) { throw new Error('Postmark is not configured. Add your Server Token.') } const client = new PostmarkClient(apiKey) if (options.attachments?.length) { await client.sendEmail({ From: options.from, To: options.to, ReplyTo: options.replyTo, Subject: options.subject, HtmlBody: options.html, TextBody: options.text, Attachments: options.attachments.map((a) => ({ Name: a.filename, Content: a.content.toString('base64'), ContentType: 'application/octet-stream', ContentID: '', })), }) } else { await client.sendEmail({ From: options.from, To: options.to, ReplyTo: options.replyTo, Subject: options.subject, HtmlBody: options.html, TextBody: options.text, }) } } async function sendViaPostmark(options: SendMailOptions) { const settings = await getSystemSettings([SYSTEM_SETTING_KEYS.POSTMARK_API_KEY]) await sendViaPostmarkWithSettings(options, settings, SYSTEM_SETTING_KEYS.POSTMARK_API_KEY) } // ─── Mailgun ─────────────────────────────────────────────────────────────── async function sendViaMailgunWithSettings( options: SendMailOptions, settings: SettingsMap, keys: { apiKey: string; domain: string; region: string } ) { const apiKey = settings.get(keys.apiKey) const domain = settings.get(keys.domain) const region = settings.get(keys.region) || 'us' if (!apiKey || !domain) { throw new Error('Mailgun is not configured. Add your API key and domain.') } const mailgun = new Mailgun(FormData) const mg = mailgun.client({ username: 'api', key: apiKey, url: region === 'eu' ? 'https://api.eu.mailgun.net' : undefined, }) await mg.messages.create(domain, { from: options.from, to: [options.to], ...(options.replyTo && { 'h:Reply-To': options.replyTo }), subject: options.subject, html: options.html, ...(options.text && { text: options.text }), ...(options.attachments?.length && { attachment: options.attachments.map((a) => ({ filename: a.filename, data: a.content, })), }), }) } async function sendViaMailgun(options: SendMailOptions) { const keys = [ SYSTEM_SETTING_KEYS.MAILGUN_API_KEY, SYSTEM_SETTING_KEYS.MAILGUN_DOMAIN, SYSTEM_SETTING_KEYS.MAILGUN_REGION, ] const settings = await getSystemSettings(keys) await sendViaMailgunWithSettings(options, settings, { apiKey: SYSTEM_SETTING_KEYS.MAILGUN_API_KEY, domain: SYSTEM_SETTING_KEYS.MAILGUN_DOMAIN, region: SYSTEM_SETTING_KEYS.MAILGUN_REGION, }) } // ─── SendGrid ────────────────────────────────────────────────────────────── async function sendViaSendGridWithSettings( options: SendMailOptions, settings: SettingsMap, apiKeyField: string ) { const apiKey = settings.get(apiKeyField) if (!apiKey) { throw new Error('SendGrid is not configured. Add your API key.') } sgMail.setApiKey(apiKey) const msg: MailDataRequired = { from: options.from, to: options.to, replyTo: options.replyTo, subject: options.subject, html: options.html, ...(options.text && { text: options.text }), } if (options.attachments?.length) { msg.attachments = options.attachments.map((a) => ({ filename: a.filename, content: a.content.toString('base64'), type: 'application/octet-stream', disposition: 'attachment' as const, })) } await sgMail.send(msg) } async function sendViaSendGrid(options: SendMailOptions) { const settings = await getSystemSettings([SYSTEM_SETTING_KEYS.SENDGRID_API_KEY]) await sendViaSendGridWithSettings(options, settings, SYSTEM_SETTING_KEYS.SENDGRID_API_KEY) } // ─── Amazon SES ──────────────────────────────────────────────────────────── async function sendViaSesWithSettings( options: SendMailOptions, settings: SettingsMap, keys: { accessKeyId: string; secretAccessKey: string; region: string } ) { const accessKeyId = settings.get(keys.accessKeyId) const secretAccessKey = settings.get(keys.secretAccessKey) const region = settings.get(keys.region) || 'us-east-1' if (!accessKeyId || !secretAccessKey) { throw new Error('Amazon SES is not configured. Add your credentials.') } const transporter = nodemailer.createTransport({ streamTransport: true }) const info = await transporter.sendMail({ from: options.from, to: options.to, replyTo: options.replyTo, subject: options.subject, html: options.html, text: options.text, attachments: options.attachments?.map((a) => ({ filename: a.filename, content: a.content, })), }) const rawMessage = Buffer.isBuffer(info.message) ? info.message : await streamToBuffer(info.message) const client = new SESClient({ region, credentials: { accessKeyId, secretAccessKey }, }) await client.send(new SendRawEmailCommand({ RawMessage: { Data: rawMessage } })) } async function sendViaSes(options: SendMailOptions) { const keys = [ SYSTEM_SETTING_KEYS.SES_ACCESS_KEY_ID, SYSTEM_SETTING_KEYS.SES_SECRET_ACCESS_KEY, SYSTEM_SETTING_KEYS.SES_REGION, ] const settings = await getSystemSettings(keys) await sendViaSesWithSettings(options, settings, { accessKeyId: SYSTEM_SETTING_KEYS.SES_ACCESS_KEY_ID, secretAccessKey: SYSTEM_SETTING_KEYS.SES_SECRET_ACCESS_KEY, region: SYSTEM_SETTING_KEYS.SES_REGION, }) } function streamToBuffer(stream: NodeJS.ReadableStream): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = [] stream.on('data', (chunk: Buffer) => chunks.push(chunk)) stream.on('end', () => resolve(Buffer.concat(chunks))) stream.on('error', reject) }) } // ─── Provider dispatch (shared by both system and org) ────────────────────── function sendWithProvider( provider: EmailProvider, options: SendMailOptions, settings: SettingsMap, keySet: 'system' | 'org' ) { if (keySet === 'org') { switch (provider) { case 'smtp': return sendViaSmtpWithSettings( options, settings, { host: ORG_EMAIL_KEYS.EMAIL_SMTP_HOST, port: ORG_EMAIL_KEYS.EMAIL_SMTP_PORT, user: ORG_EMAIL_KEYS.EMAIL_SMTP_USER, pass: ORG_EMAIL_KEYS.EMAIL_SMTP_PASS, secure: ORG_EMAIL_KEYS.EMAIL_SMTP_SECURE, rejectUnauthorized: ORG_EMAIL_KEYS.EMAIL_SMTP_REJECT_UNAUTHORIZED, requireTls: ORG_EMAIL_KEYS.EMAIL_SMTP_REQUIRE_TLS, }, false ) case 'resend': return sendViaResendWithSettings(options, settings, ORG_EMAIL_KEYS.EMAIL_RESEND_API_KEY) case 'postmark': return sendViaPostmarkWithSettings(options, settings, ORG_EMAIL_KEYS.EMAIL_POSTMARK_API_KEY) case 'mailgun': return sendViaMailgunWithSettings(options, settings, { apiKey: ORG_EMAIL_KEYS.EMAIL_MAILGUN_API_KEY, domain: ORG_EMAIL_KEYS.EMAIL_MAILGUN_DOMAIN, region: ORG_EMAIL_KEYS.EMAIL_MAILGUN_REGION, }) case 'sendgrid': return sendViaSendGridWithSettings(options, settings, ORG_EMAIL_KEYS.EMAIL_SENDGRID_API_KEY) case 'ses': return sendViaSesWithSettings(options, settings, { accessKeyId: ORG_EMAIL_KEYS.EMAIL_SES_ACCESS_KEY_ID, secretAccessKey: ORG_EMAIL_KEYS.EMAIL_SES_SECRET_ACCESS_KEY, region: ORG_EMAIL_KEYS.EMAIL_SES_REGION, }) } } // system keySet switch (provider) { case 'resend': return sendViaResend(options) case 'postmark': return sendViaPostmark(options) case 'mailgun': return sendViaMailgun(options) case 'sendgrid': return sendViaSendGrid(options) case 'ses': return sendViaSes(options) default: return sendViaSmtp(options) } } // ─── System-level router (unchanged behavior) ────────────────────────────── export async function sendMail(options: SendMailOptions) { const provider = await getEmailProvider() await sendWithProvider(provider, options, new Map(), 'system') } // ─── Org-level email ──────────────────────────────────────────────────────── function isEmailProvider(value: string | null | undefined): value is EmailProvider { return ( value === 'smtp' || value === 'resend' || value === 'postmark' || value === 'mailgun' || value === 'sendgrid' || value === 'ses' ) } /** * The workshop's own mail vendor, from the integration it connected. An * organization that never set one up falls through to the platform's mail * settings, which is what keeps email working everywhere by default. */ async function getOrgEmailProvider(organizationId: string): Promise { const value = await channelProvider(organizationId, 'email') return isEmailProvider(value) ? value : null } export async function getOrgFromAddress(organizationId: string): Promise { const provider = await getOrgEmailProvider(organizationId) if (!provider) { return getFromAddress() } const settings = await channelSettings(organizationId, 'email') return orgFromAddressFor(provider, settings) } /** * Send through one specific email connection rather than whichever the * organization is pointed at. The connection page's "send a test email" runs * through here, so the vendor being looked at is the vendor being tested, * even while a second one is still connected. */ export async function sendMailThroughConnection( connectorId: string, credentials: Record, settings: Record, options: Omit ): Promise<{ from: string }> { const provider = messagingProvider(connectorId) if (!provider || !isEmailProvider(provider.legacyProvider)) { throw new Error(`${connectorId} is not an email integration`) } const map = asLegacyMap({ connectionId: '', connectorId, provider, credentials, settings, }) const from = orgFromAddressFor(provider.legacyProvider, map) await sendWithProvider(provider.legacyProvider, { ...options, from }, map, 'org') return { from } } function orgFromAddressFor(provider: EmailProvider, settings: SettingsMap): string { const keyMap: Record = { smtp: { email: ORG_EMAIL_KEYS.EMAIL_SMTP_FROM_EMAIL, name: ORG_EMAIL_KEYS.EMAIL_SMTP_FROM_NAME, }, resend: { email: ORG_EMAIL_KEYS.EMAIL_RESEND_FROM_EMAIL, name: ORG_EMAIL_KEYS.EMAIL_RESEND_FROM_NAME, }, postmark: { email: ORG_EMAIL_KEYS.EMAIL_POSTMARK_FROM_EMAIL, name: ORG_EMAIL_KEYS.EMAIL_POSTMARK_FROM_NAME, }, mailgun: { email: ORG_EMAIL_KEYS.EMAIL_MAILGUN_FROM_EMAIL, name: ORG_EMAIL_KEYS.EMAIL_MAILGUN_FROM_NAME, }, sendgrid: { email: ORG_EMAIL_KEYS.EMAIL_SENDGRID_FROM_EMAIL, name: ORG_EMAIL_KEYS.EMAIL_SENDGRID_FROM_NAME, }, ses: { email: ORG_EMAIL_KEYS.EMAIL_SES_FROM_EMAIL, name: ORG_EMAIL_KEYS.EMAIL_SES_FROM_NAME, }, } const keys = keyMap[provider] const fromEmail = settings.get(keys.email) || 'noreply@example.com' const fromName = settings.get(keys.name) || 'Torqvoice' return `${fromName} <${fromEmail}>` } export async function sendOrgMail(organizationId: string, options: SendMailOptions) { const settings = await channelSettings(organizationId, 'email') const provider = settings.get(ORG_EMAIL_KEYS.EMAIL_PROVIDER) if (!isEmailProvider(provider)) { // A workshop that named a vendor but never finished its setup used to // get that vendor's error. It still does, rather than a quiet send from // the platform's account that its customers would not recognise. const named = await legacyProviderNamed(organizationId, 'email') if (named) { throw new Error(`Email provider ${named} is not fully configured. Check its integration.`) } // Fall back to global platform email await sendMail(options) return } await sendWithProvider(provider, options, settings, 'org') }