91 lines
3.6 KiB
TypeScript
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
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 · rspace.online</p>
|
|
</div></body></html>`;
|
|
}
|