import nodemailer from "nodemailer";
import { logger } from "../logger";
import {
buildWorkshopConfirmationMail,
buildWorkshopInboxMail,
type WorkshopInquiryMailInput,
} from "../workshopSignupMail";
import { getSmtpUrl } from "./env";
import {
buildRuleStakeholderInviteMail,
type RuleStakeholderInviteMailInput,
} from "./ruleStakeholderInviteMail";
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
export function resolveMailFrom(): string {
return (
process.env.SMTP_FROM?.trim() ||
process.env.CLOUDRON_MAIL_FROM?.trim() ||
"noreply@localhost"
);
}
/** Avoid quoted-printable `token=3D` wrapping that some webmail clients do not decode. */
export const MAIL_TEXT_ENCODING = "base64" as const;
/** Plaintext + HTML for one-time verify URLs. */
export function buildVerifyLinkParts(
verifyUrl: string,
intro: string,
outro: string,
linkLabel: string,
): { text: string; html: string } {
const href = escapeHtml(verifyUrl);
const text = `${intro}\n\n<${verifyUrl}>\n\n${outro}`;
const html =
`
${escapeHtml(intro).replace(/\n/g, "
")}
` +
`${escapeHtml(linkLabel)}
` +
`${href}
` +
`${escapeHtml(outro)}
`;
return { text, html };
}
async function sendHtmlMail(opts: {
to: string;
subject: string;
text: string;
html: string;
from?: string;
replyTo?: string;
devLog: string;
}): Promise {
const smtpUrl = getSmtpUrl();
if (!smtpUrl) {
if (process.env.NODE_ENV === "development") {
logger.info(opts.devLog);
return;
}
throw new Error("CLOUDRON_MAIL_SMTP_* is not configured");
}
const transporter = nodemailer.createTransport(smtpUrl);
await transporter.sendMail({
from: opts.from ?? resolveMailFrom(),
to: opts.to,
subject: opts.subject,
text: opts.text,
html: opts.html,
textEncoding: MAIL_TEXT_ENCODING,
replyTo: opts.replyTo,
});
}
export async function sendMagicLinkEmail(
to: string,
verifyUrl: string,
): Promise {
const { text, html } = buildVerifyLinkParts(
verifyUrl,
"Open this link to log in (it expires in 60 minutes):",
"If you did not request this, you can ignore this email.\n\nSent because you saved a draft at communityrule.info",
"Log in",
);
await sendHtmlMail({
to,
subject: "Log in to Community Rule",
text,
html,
devLog: `[dev] Magic link for ${to}: ${verifyUrl}`,
});
}
/** Stakeholder invite after finishing a Rule (one-time link, same Mailhog pattern as magic link). */
export async function sendRuleStakeholderInviteEmail(
input: RuleStakeholderInviteMailInput,
): Promise {
const { subject, text, html } = buildRuleStakeholderInviteMail(input);
await sendHtmlMail({
to: input.to,
subject,
text,
html,
replyTo: input.inviterEmail.trim().toLowerCase(),
devLog: `[dev] Rule stakeholder invite (${input.communityName}) for ${input.to}: ${input.verifyUrl}`,
});
}
/** Notify support/organizers when a visitor submits the Ask an organizer form. */
export async function sendOrganizerInquiryNotification(params: {
/** Destination inbox (e.g. from ORGANIZER_INQUIRY_TO). */
to: string;
fromEmail: string;
visitorEmail: string;
message: string;
requestId: string;
}): Promise {
const { to, fromEmail, visitorEmail, message, requestId } = params;
const text = `Request ID: ${requestId}\nFrom: ${visitorEmail}\n\n${message}\n`;
const html =
`Request ID: ${escapeHtml(requestId)}
From: ${escapeHtml(visitorEmail)}
` +
`${escapeHtml(message)}`;
await sendHtmlMail({
to,
from: fromEmail,
replyTo: visitorEmail,
subject: `Ask an organizer inquiry from ${visitorEmail}`,
text,
html,
devLog: `[dev] Organizer inquiry (request ${requestId}) from ${visitorEmail} to ${to}:\n${message}`,
});
}
export async function sendWorkshopInquiryNotification(
input: WorkshopInquiryMailInput & { to: string },
): Promise {
const { subject, text, html } = buildWorkshopInboxMail(input);
await sendHtmlMail({
to: input.to,
replyTo: input.values.email,
subject,
text,
html,
devLog: `[dev] Workshop inquiry ${input.requestId} from ${input.values.email} to ${input.to}:\n${text}`,
});
}
export async function sendWorkshopInquiryConfirmation(input: {
to: string;
largeOrganization: boolean;
schedulingUrl: string;
}): Promise {
const { subject, text, html } = buildWorkshopConfirmationMail(input);
await sendHtmlMail({
to: input.to,
subject,
text,
html,
devLog: `[dev] Workshop confirmation for ${input.to} (large=${input.largeOrganization})`,
});
}
export async function sendEmailChangeEmail(
to: string,
verifyUrl: string,
): Promise {
const { text, html } = buildVerifyLinkParts(
verifyUrl,
"You asked to change the email on your Community Rule account.\n\nOpen this link to confirm the new address (it expires in 60 minutes):",
"If you did not request this change, you can ignore this email. Your current login is unchanged until you confirm.",
"Confirm email",
);
await sendHtmlMail({
to,
subject: "Confirm your new Community Rule email",
text,
html,
devLog: `[dev] Email change verify for ${to}: ${verifyUrl}`,
});
}