Ctrl K
ComunidadGuías…

webapps/shared/src/email.tsx

Rama newjitsu · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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 <john.doe@gmail.com>' 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<typeof EmailEnvSettings>;

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<T>(...args: (T | undefined)[]): T {
  return args.find(arg => arg !== undefined) as T;
}

type DiscriminatedUnion<T1, T2> =
  | (T1 & { [K in Exclude<keyof T2, keyof T1>]?: never })
  | (T2 & { [K in Exclude<keyof T1, keyof T2>]?: never });

export type EmailSendingResult = Simplify<
  DiscriminatedUnion<
    { sent: false; reasonNotSent: string },
    { sent: true; subject: string; text: string; messageId?: string; dryRun?: boolean }
  >
>;

export async function sendEmail<P extends UnsubscribeLinkProps>(
  template: EmailTemplate<P>,
  props: P,
  to: string | string[],
  opts: { testToBcc?: boolean; dryRun?: boolean } = { testToBcc: false, dryRun: false }
): Promise<EmailSendingResult> {
  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(<ReactBody {...props} />);
  const text = template.plaintext
    ? template.plaintext(props)
    : await render(<ReactBody {...props} />, { 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 };
}