272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
import { ASSETS, getAssetPath } from "../assetUtils";
|
|
import {
|
|
clipCommunityInviteDescription,
|
|
communityInviteInitials,
|
|
communityPossessive,
|
|
inviteRuleCountsLabel,
|
|
} from "../create/ruleInviteCardMeta";
|
|
|
|
export const RULE_STAKEHOLDER_INVITE_LOGO_PATH = getAssetPath(ASSETS.LOGO_MARK);
|
|
|
|
/** Letter name when neither the inviter nor the rule owner has an email. */
|
|
export const RULE_STAKEHOLDER_INVITER_FALLBACK = "Someone from this community";
|
|
|
|
const MAILBOX_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
/** Reply-To only when the inviter value is a mailbox. */
|
|
export function stakeholderInviteReplyTo(
|
|
inviterEmail: string,
|
|
): string | undefined {
|
|
const mailbox = inviterEmail.trim().toLowerCase();
|
|
if (!MAILBOX_PATTERN.test(mailbox)) return undefined;
|
|
return mailbox;
|
|
}
|
|
|
|
/** Email addresses stay lowercase; any other label is kept for the letter. */
|
|
export function stakeholderInviteInviterLabel(inviterEmail: string): string {
|
|
const replyTo = stakeholderInviteReplyTo(inviterEmail);
|
|
if (replyTo) return replyTo;
|
|
const trimmed = inviterEmail.trim();
|
|
return trimmed.length > 0 ? trimmed : RULE_STAKEHOLDER_INVITER_FALLBACK;
|
|
}
|
|
|
|
const PREVIEW_TEXT =
|
|
"It covers how they talk, who belongs, how they decide and what happens when they disagree.";
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
export function ruleStakeholderInviteCopyUrl(verifyUrl: string): string {
|
|
return `${verifyUrl}${verifyUrl.includes("?") ? "&" : "?"}copy=1`;
|
|
}
|
|
|
|
export type RuleStakeholderInviteMailInput = {
|
|
to: string;
|
|
verifyUrl: string;
|
|
origin: string;
|
|
communityName: string;
|
|
inviterEmail: string;
|
|
summary?: string | null;
|
|
document?: unknown;
|
|
};
|
|
|
|
export type RuleStakeholderInviteMailContent = {
|
|
subject: string;
|
|
previewText: string;
|
|
text: string;
|
|
html: string;
|
|
};
|
|
|
|
export function buildRuleStakeholderInviteMail(
|
|
input: RuleStakeholderInviteMailInput,
|
|
): RuleStakeholderInviteMailContent {
|
|
const communityName = input.communityName.trim() || "A community";
|
|
const inviterEmail = stakeholderInviteInviterLabel(input.inviterEmail);
|
|
const origin = input.origin.replace(/\/$/, "");
|
|
const verifyUrl = input.verifyUrl;
|
|
const copyUrl = ruleStakeholderInviteCopyUrl(verifyUrl);
|
|
const possessive = communityPossessive(communityName);
|
|
const description = clipCommunityInviteDescription(input.summary);
|
|
const initials = communityInviteInitials(communityName);
|
|
const countsLabel = inviteRuleCountsLabel(input.document);
|
|
const subject = `${communityName} invited you to read their Rule`;
|
|
const intro = `${inviterEmail} sent the invitation. Their Rule sets out how they talk, who belongs, how they decide and what happens when they disagree.`;
|
|
const privacy = `Every Rule starts private. Only the people ${communityName} invites can read it until the person who holds the pen turns on a link. A Rule is public and featured only when a community asks us in writing.`;
|
|
const ownLink =
|
|
"Your own link. Forwarding this email shares it. Ask the community to invite anyone else who should read the Rule.";
|
|
const copyLead =
|
|
"Writing a Rule for another community? Make a copy of this one and change what does not fit.";
|
|
const why = `You got this email because ${inviterEmail} added your address to ${possessive} Rule on CommunityRule. If you do not know the community, you can ignore this email.`;
|
|
const madeBy =
|
|
"CommunityRule is made by the Media Economies Design Lab at the University of Colorado Boulder.";
|
|
const privacyUrl = `${origin}/privacy`;
|
|
const aboutUrl = `${origin}/about`;
|
|
const logoUrl = `${origin}${RULE_STAKEHOLDER_INVITE_LOGO_PATH}`;
|
|
|
|
const text = [
|
|
subject,
|
|
"",
|
|
intro,
|
|
"",
|
|
"Read the Rule",
|
|
verifyUrl,
|
|
"",
|
|
privacy,
|
|
"",
|
|
ownLink,
|
|
"",
|
|
copyLead,
|
|
copyUrl,
|
|
"",
|
|
why,
|
|
"",
|
|
madeBy,
|
|
`Privacy ${privacyUrl}`,
|
|
].join("\n");
|
|
|
|
const html = inviteHtml({
|
|
subject,
|
|
intro,
|
|
verifyUrl,
|
|
copyUrl,
|
|
communityName,
|
|
description,
|
|
initials,
|
|
countsLabel,
|
|
privacy,
|
|
ownLink,
|
|
copyLead,
|
|
why,
|
|
madeBy,
|
|
privacyUrl,
|
|
aboutUrl,
|
|
logoUrl,
|
|
});
|
|
|
|
return { subject, previewText: PREVIEW_TEXT, text, html };
|
|
}
|
|
|
|
function inviteHtml(opts: {
|
|
subject: string;
|
|
intro: string;
|
|
verifyUrl: string;
|
|
copyUrl: string;
|
|
communityName: string;
|
|
description: string;
|
|
initials: string;
|
|
countsLabel: string;
|
|
privacy: string;
|
|
ownLink: string;
|
|
copyLead: string;
|
|
why: string;
|
|
madeBy: string;
|
|
privacyUrl: string;
|
|
aboutUrl: string;
|
|
logoUrl: string;
|
|
}): string {
|
|
const heading = escapeHtml(opts.subject);
|
|
const intro = escapeHtml(opts.intro);
|
|
const verifyHref = escapeHtml(opts.verifyUrl);
|
|
const copyHref = escapeHtml(opts.copyUrl);
|
|
const name = escapeHtml(opts.communityName);
|
|
const description = escapeHtml(opts.description);
|
|
const initials = escapeHtml(opts.initials);
|
|
const counts = escapeHtml(opts.countsLabel);
|
|
const privacyHtml = privacyHtmlWithLead(opts.privacy);
|
|
const ownLinkHtml = leadInHtml("Your own link.", opts.ownLink);
|
|
const copyHtml =
|
|
`<strong style="color:#000000;">Writing a Rule for another community?</strong>` +
|
|
` <a href="${copyHref}" style="color:#000000;text-decoration:underline;">Make a copy</a>` +
|
|
` of this one and change what does not fit.`;
|
|
const why = escapeHtml(opts.why);
|
|
const madeBy = escapeHtml(opts.madeBy);
|
|
const privacyUrl = escapeHtml(opts.privacyUrl);
|
|
const aboutUrl = escapeHtml(opts.aboutUrl);
|
|
const logoUrl = escapeHtml(opts.logoUrl);
|
|
const cardBody = description
|
|
? `<p style="margin:16px 0 0;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:18px;font-weight:500;color:#000000;">${description}</p>`
|
|
: "";
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
<title>${heading}</title>
|
|
</head>
|
|
<body style="margin:0;padding:0;background:#e4e4e4;">
|
|
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${escapeHtml(
|
|
PREVIEW_TEXT,
|
|
)}</div>
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#e4e4e4;">
|
|
<tr>
|
|
<td align="center">
|
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px;max-width:600px;background:#ffffff;">
|
|
<tr>
|
|
<td style="background:#000000;padding:24px 32px;">
|
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
|
<tr>
|
|
<td style="font-family:'Bricolage Grotesque',Arial,sans-serif;font-size:22px;line-height:27px;color:#ffffff;padding-right:6px;">CommunityRule</td>
|
|
<td>
|
|
<img src="${logoUrl}" width="27" height="27" alt="" style="display:block;border:0;width:27px;height:27px;" />
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding:40px 32px 56px;">
|
|
<h1 style="margin:0 0 16px;font-family:'Bricolage Grotesque',Arial,sans-serif;font-size:36px;line-height:44px;font-weight:700;color:#000000;">${heading}</h1>
|
|
<p style="margin:0 0 32px;font-family:Inter,Arial,sans-serif;font-size:18px;line-height:24px;color:#303030;">${intro}</p>
|
|
<p style="margin:0 0 16px;">
|
|
<a href="${verifyHref}" style="display:inline-block;background:#141414;color:#ffffff;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:20px;font-weight:500;text-decoration:none;border-radius:9999px;padding:12px 16px;">Read the Rule</a>
|
|
</p>
|
|
<p style="margin:0 0 4px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">Or paste this link into a browser</p>
|
|
<p style="margin:0 0 32px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;">
|
|
<a href="${verifyHref}" style="color:#000000;text-decoration:underline;">${verifyHref}</a>
|
|
</p>
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#c9fef9;border-radius:12px;">
|
|
<tr>
|
|
<td style="padding:16px;">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-bottom:1px solid #000000;">
|
|
<tr>
|
|
<td width="74" valign="middle" style="padding:8px 8px 8px 4px;">
|
|
<div style="width:56px;height:56px;border-radius:9999px;background:#fffdd2;font-family:Inter,Arial,sans-serif;font-size:24px;line-height:56px;font-weight:500;color:#000000;text-align:center;">${initials}</div>
|
|
</td>
|
|
<td valign="middle" style="border-left:1px solid #000000;padding:12px 16px;font-family:'Bricolage Grotesque',Arial,sans-serif;font-size:24px;line-height:32px;font-weight:700;color:#000000;">${name}</td>
|
|
</tr>
|
|
</table>
|
|
${cardBody}
|
|
<p style="margin:16px 0 0;">
|
|
<span style="display:inline-block;background:#303030;color:#c9fef9;font-family:Inter,Arial,sans-serif;font-size:10px;line-height:12px;font-weight:500;letter-spacing:0.2px;text-transform:uppercase;border-radius:2px;padding:2px 4px;">${counts}</span>
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
<div style="height:1px;background:#e4e4e4;margin:32px 0;"></div>
|
|
<p style="margin:0 0 20px;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:24px;color:#303030;">${privacyHtml}</p>
|
|
<p style="margin:0 0 20px;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:24px;color:#303030;">${ownLinkHtml}</p>
|
|
<p style="margin:0 0 32px;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:24px;color:#303030;">${copyHtml}</p>
|
|
<div style="border-top:1px solid #e4e4e4;padding-top:24px;">
|
|
<p style="margin:0 0 12px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">${why}</p>
|
|
<p style="margin:0 0 12px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">${madeBy}</p>
|
|
<p style="margin:0;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">
|
|
<a href="${privacyUrl}" style="color:#464646;text-decoration:underline;">Privacy</a>
|
|
|
|
<a href="${aboutUrl}" style="color:#464646;text-decoration:underline;">About CommunityRule</a>
|
|
</p>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function privacyHtmlWithLead(privacy: string): string {
|
|
const lead = "Every Rule starts private.";
|
|
if (privacy.startsWith(lead)) {
|
|
return `<strong style="color:#000000;">${escapeHtml(lead)}</strong>${escapeHtml(
|
|
privacy.slice(lead.length),
|
|
)}`;
|
|
}
|
|
return escapeHtml(privacy);
|
|
}
|
|
|
|
function leadInHtml(lead: string, full: string): string {
|
|
if (full.startsWith(lead)) {
|
|
return `<strong style="color:#000000;">${escapeHtml(lead)}</strong>${escapeHtml(
|
|
full.slice(lead.length),
|
|
)}`;
|
|
}
|
|
return escapeHtml(full);
|
|
}
|