| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669 |
- 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<string, string>
- async function getSystemSettings(keys: string[]): Promise<SettingsMap> {
- 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<EmailProvider> {
- 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<string> {
- const provider = await getEmailProvider()
- const keyMap: Record<EmailProvider, { email: string; name: string }> = {
- 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<Uint8Array> {
- 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<EmailProvider | null> {
- const value = await channelProvider(organizationId, 'email')
- return isEmailProvider(value) ? value : null
- }
- export async function getOrgFromAddress(organizationId: string): Promise<string> {
- 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<string, string>,
- settings: Record<string, unknown>,
- options: Omit<SendMailOptions, 'from'>
- ): 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<EmailProvider, { email: string; name: string }> = {
- 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')
- }
|