import dayjs from "dayjs"; import { z } from "zod"; import utc from "dayjs/plugin/utc"; import { render } from "@react-email/render"; import { EmailTemplate, UnsubscribeLinkProps } from "./types"; import { getErrorMessage } from "juava"; import { Simplify } from "type-fest"; import nodemailer from "nodemailer"; import Mail from "nodemailer/lib/mailer"; dayjs.extend(utc); const transport = initNodeMailer(); /** * Parse an email address into a name and email. Accepts 'John Doe ' or just * john.doe@gmail.com * @param input */ function parseEmailAddress(input: string): { name?: string; email: string } { const match = input.match(/^\s*(.*)\s*<([^<>]+)>\s*$/); if (match) { return { name: match[1].trim(), email: match[2].trim() }; } return { email: input.trim() }; } export const EmailEnvSettings = z.object({ EMAIL_TRANSACTIONAL_SENDER: z.string(), EMAIL_TRANSACTIONAL_REPLY_TO: z.string().optional(), EMAIL_MARKETING_SENDER: z.string().optional(), EMAIL_MARKETING_REPLY_TO: z.string().optional(), BCC_EMAIL: z.string().email().optional(), }); export type EmailEnvSettings = z.infer; export function getEmailEnvSettings(): EmailEnvSettings { return EmailEnvSettings.parse(process.env); } export function isEmailAvailable(): boolean { return !!transport; } export function newId() { return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2); } function getDomainFromEmail(email: string): string { return parseEmailAddress(email).email.split("@")[1]; } function firstDefined(...args: (T | undefined)[]): T { return args.find(arg => arg !== undefined) as T; } type DiscriminatedUnion = | (T1 & { [K in Exclude]?: never }) | (T2 & { [K in Exclude]?: never }); export type EmailSendingResult = Simplify< DiscriminatedUnion< { sent: false; reasonNotSent: string }, { sent: true; subject: string; text: string; messageId?: string; dryRun?: boolean } > >; export async function sendEmail

( template: EmailTemplate

, props: P, to: string | string[], opts: { testToBcc?: boolean; dryRun?: boolean } = { testToBcc: false, dryRun: false } ): Promise { if (!isEmailAvailable() && !opts.dryRun) { console.warn("Email is not available, skipping sending email"); return { sent: false, reasonNotSent: "Email is not available" }; } const env = getEmailEnvSettings(); const isMarketingEmail = firstDefined(template.isMarketingEmail, false); const from = template.from || (isMarketingEmail ? env.EMAIL_MARKETING_SENDER : env.EMAIL_TRANSACTIONAL_SENDER); const replyTo = template.replyTo || (isMarketingEmail ? env.EMAIL_MARKETING_REPLY_TO : env.EMAIL_TRANSACTIONAL_REPLY_TO); const domain = getDomainFromEmail(from!); // const scheduledAt = template.scheduleAt ? template.scheduleAt(new Date()) : undefined; let subject = template.subject(props); if (opts?.testToBcc) { subject = `[Test - for ${to}] ${subject}`; to = env.BCC_EMAIL ?? ""; } console.log(`Sending email to ${to} From: ${from}, replyTo: ${replyTo}. Subject: ${subject}`); const ReactBody = template; const html = await render(); const text = template.plaintext ? template.plaintext(props) : await render(, { plainText: true }); const options: Mail.Options = { from, replyTo, to, bcc: template.bcc || env.BCC_EMAIL, subject, headers: { "Message-ID": `${newId()}@${domain}`, }, html, text, }; if (!opts.dryRun) { const res = await transport!.sendMail(options); return { sent: true, subject, text, messageId: res.messageId || "", }; } else { return { sent: true, dryRun: true, subject, text, }; } } function initNodeMailer() { if (process.env.SMTP_CONNECTION_STRING) { const { host, port, user, password } = parseConnectionString(process.env.SMTP_CONNECTION_STRING); try { const credentials = { host, port: parseInt(port), auth: { user, pass: password, }, secure: parseInt(port) === 465, tls: { rejectUnauthorized: false, }, }; //console.log("SMTP credentials", credentials) return nodemailer.createTransport(credentials); } catch (e) { console.error( `Error initializing SMTP transport ${process.env.SMTP_CONNECTION_STRING}: ${getErrorMessage(e)}`, e ); throw new Error(`Can't connect to SMTP server`); } } else { return undefined; } } function parseConnectionString(connectionString: string) { if (connectionString.startsWith("smtp://")) { connectionString = connectionString.substring("smtp://".length); } const atIndex = connectionString.lastIndexOf("@"); if (atIndex < 0) { throw new Error(`Invalid SMTP connection string ${connectionString}`); } const auth = connectionString.substring(0, atIndex); const hostAndPort = connectionString.substring(atIndex + 1); const [host, port = "587"] = hostAndPort.split(":"); const colonIndex = auth.lastIndexOf(":"); const [user, password] = colonIndex < 0 ? [auth, ""] : [auth.substring(0, colonIndex), auth.substring(colonIndex + 1)]; return { host, port, user, password }; }