All files / src/lib email.ts

95.65% Statements 22/23
91.66% Branches 11/12
100% Functions 5/5
100% Lines 22/22

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90                            16x 16x     11x 11x 11x 11x 11x                           11x   11x     9x 1x   9x   1x   9x     2x       2x             2x   1x     1x           11x       3x           2x            
import { Resend } from "resend";
import { env } from "@/lib/env";
 
// Transactional email via Resend. Invitation links carry a bearer token in the
// URL, so the URL is a secret — it goes ONLY to the recipient's send, and is
// NEVER logged, captured, or echoed anywhere outside NODE_ENV==="development".
//
// Graceful fallback: if RESEND_API_KEY is unset we do NOT construct a client and do NOT throw —
// sends are skipped so CI/tests/local stay green without a key. In development
// only, we console.log the link so a dev can click it locally.
 
type InvitationArgs = { to: string; token: string };
 
// Cache the client so we build it once. `null` = key unset (fallback mode).
let client: Resend | null = null;
let resolved = false;
 
function getClient(): Resend | null {
  Iif (resolved) return client;
  resolved = true;
  const key = process.env.RESEND_API_KEY;
  client = key ? new Resend(key) : null;
  return client;
}
 
// `buildUrl` is a thunk so the caller can defer any env access (e.g. AUTH_URL)
// until AFTER the no-op guard below — the no-key path must never touch env vars
// that the send doesn't need. `html` is built from the resolved url, so it too
// is deferred past the guard.
async function send(args: {
  to: string;
  subject: string;
  devLabel: string;
  buildUrl: () => string;
  html: (url: string) => string;
}): Promise<{ sent: boolean }> {
  const resend = getClient();
 
  if (!resend) {
    // No key: no-op. Surface the link only for local dev clicking — never in
    // test/production, so the bearer token never lands in shared logs.
    if (process.env.NODE_ENV === "development") {
      console.log(`[DEV ${args.devLabel}] ${args.to}\n${args.buildUrl()}`);
    }
    if (process.env.NODE_ENV === "production") {
      // Static string — never interpolate token/email here (Security pin).
      console.error("[email] RESEND_API_KEY missing — invitation email NOT sent");
    }
    return { sent: false };
  }
 
  const url = args.buildUrl();
 
  // Recipient scoped to `to` only — no cc/bcc, so the token-bearing link never
  // reaches a second address.
  const { error } = await resend.emails.send({
    from: env.EMAIL_FROM,
    to: args.to,
    subject: args.subject,
    html: args.html(url),
  });
 
  if (error) {
    // Never include the url/token in the thrown error or any log.
    throw new Error(`Failed to send email: ${error.message}`);
  }
 
  return { sent: true };
}
 
export async function sendInvitation({ to, token }: InvitationArgs): Promise<{ sent: boolean }> {
  // Build the invite URL INSIDE the send, after the no-op guard, so a missing
  // RESEND_API_KEY (which no-ops) never forces env.AUTH_URL to be present.
  return send({
    to,
    devLabel: "INVITATION",
    subject: "You've been invited to a collection on Adored",
    buildUrl: () => `${env.AUTH_URL}/invitations/${token}`,
    html: invitationHtml,
  });
}
 
function invitationHtml(url: string): string {
  return `
    <p>You've been invited to share a collection on Adored.</p>
    <p><a href="${url}">Open your invitation</a></p>
    <p>If you weren't expecting this, you can ignore this email.</p>
  `;
}