rspace-online/server/magic-link/send.ts

91 lines
3.6 KiB
TypeScript

/** Send magic link invitations via email. */
import { magicLinkConfig } from "./config";
import { createMagicToken } from "./token";
export interface MagicLinkRecipient {
name: string;
email: string;
}
export interface SendMagicLinkOpts {
space: string;
type: "poll" | "rsvp";
targetId: string;
/** Title of the poll session or event (for email subject) */
title: string;
participants: MagicLinkRecipient[];
/** If false, only return URLs without sending emails */
sendEmail?: boolean;
}
export interface MagicLinkResult {
name: string;
email: string;
url: string;
sent: boolean;
}
export async function sendMagicLinks(opts: SendMagicLinkOpts): Promise<MagicLinkResult[]> {
const results: MagicLinkResult[] = [];
for (const participant of opts.participants) {
const token = await createMagicToken(opts.space, opts.type, opts.targetId, participant.name);
const url = `${magicLinkConfig.baseUrl}/respond/${token}`;
let sent = false;
if (opts.sendEmail !== false && participant.email) {
try {
const nodemailer = await import("nodemailer");
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST || "mailcowdockerized-postfix-mailcow-1",
port: Number(process.env.SMTP_PORT) || 587,
secure: false,
auth: {
user: process.env.SMTP_USER || "noreply@rmail.online",
pass: process.env.SMTP_PASS,
},
tls: { rejectUnauthorized: false },
});
const typeLabel = opts.type === "poll" ? "Poll" : "RSVP";
const subject = `${typeLabel}: ${opts.title}`;
const html = buildInviteEmail(opts, participant, url);
await transport.sendMail({
from: process.env.SMTP_FROM || `rSpace <${process.env.SMTP_USER || "noreply@rmail.online"}>`,
to: participant.email,
subject,
html,
});
sent = true;
} catch (err) {
console.error(`[MagicLink] Email send failed for ${participant.email}:`, err);
}
}
results.push({ name: participant.name, email: participant.email, url, sent });
}
return results;
}
function esc(str: string): string {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function buildInviteEmail(opts: SendMagicLinkOpts, participant: MagicLinkRecipient, url: string): string {
const typeLabel = opts.type === "poll" ? "Poll" : "Event RSVP";
const actionLabel = opts.type === "poll" ? "Vote Now" : "RSVP Now";
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8fafc;color:#1e293b;margin:0;padding:20px">
<div style="max-width:500px;margin:40px auto;background:#fff;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,.1);padding:32px">
<h1 style="font-size:20px;margin:0 0 4px;color:#0f172a">${esc(opts.title)}</h1>
<p style="font-size:14px;color:#64748b;margin:8px 0 24px">Hi ${esc(participant.name)}, you've been invited to respond to this ${typeLabel.toLowerCase()}.</p>
<a href="${esc(url)}" style="display:inline-block;padding:14px 28px;background:#6366f1;color:#fff;border-radius:10px;text-decoration:none;font-size:16px;font-weight:600">${actionLabel}</a>
<p style="font-size:12px;color:#94a3b8;margin-top:24px">This link expires in ${magicLinkConfig.tokenExpiryDays} days. No account required.</p>
<p style="font-size:11px;color:#cbd5e1;margin-top:16px">Sent by rSpace &middot; rspace.online</p>
</div></body></html>`;
}