|
| 1 | +import { type EmailSender, senderAddress } from "../../email/sender.js"; |
| 2 | +import { |
| 3 | + type ChannelDelivery, |
| 4 | + ChannelDeliveryError, |
| 5 | + type NotificationChannel, |
| 6 | +} from "./channel.js"; |
| 7 | + |
| 8 | +const escapeHtml = (value: string) => |
| 9 | + value |
| 10 | + .replaceAll("&", "&") |
| 11 | + .replaceAll("<", "<") |
| 12 | + .replaceAll(">", ">") |
| 13 | + .replaceAll('"', """); |
| 14 | + |
| 15 | +const initial = (name: string | undefined) => |
| 16 | + (name?.trim().at(0) ?? "?").toUpperCase(); |
| 17 | + |
| 18 | +const capitalized = (value: string) => |
| 19 | + `${value.at(0)?.toUpperCase() ?? ""}${value.slice(1)}`; |
| 20 | + |
| 21 | +/** A notification email: who did what, the message in a card, one link. */ |
| 22 | +export const notificationEmail = ({ |
| 23 | + content, |
| 24 | + topicNoun, |
| 25 | + links, |
| 26 | +}: ChannelDelivery) => { |
| 27 | + const open = escapeHtml(links.open); |
| 28 | + const unsubscribe = escapeHtml(links.unsubscribe); |
| 29 | + const unsubscribeLabel = `Unsubscribe from ${topicNoun} emails`; |
| 30 | + const text = [ |
| 31 | + content.headline, |
| 32 | + "", |
| 33 | + content.title, |
| 34 | + content.body, |
| 35 | + "", |
| 36 | + `Open thread: ${links.open}`, |
| 37 | + "", |
| 38 | + `You received this email because ${content.reason}.`, |
| 39 | + `${unsubscribeLabel}: ${links.unsubscribe}`, |
| 40 | + ].join("\n"); |
| 41 | + const html = `<!doctype html> |
| 42 | +<html lang="en"> |
| 43 | + <body style="margin:0;padding:0;background:#ffffff;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#14171a;"> |
| 44 | + <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px;"> |
| 45 | + <tr><td align="center"> |
| 46 | + <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;"> |
| 47 | + <tr><td style="font-size:26px;font-weight:700;letter-spacing:-0.03em;padding-bottom:28px;">dx</td></tr> |
| 48 | + <tr><td style="font-size:15px;line-height:22px;color:#55595e;padding-bottom:16px;">${escapeHtml(content.headline)}</td></tr> |
| 49 | + <tr><td style="padding-bottom:24px;"> |
| 50 | + <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;border:1px solid #e7e5e4;border-radius:8px;"> |
| 51 | + <tr> |
| 52 | + <td width="40" valign="top" style="padding:20px 0 20px 20px;"> |
| 53 | + <div style="width:36px;height:36px;border-radius:18px;background:#1f4e8c;color:#ffffff;font-size:16px;font-weight:600;line-height:36px;text-align:center;">${escapeHtml(initial(content.actorName))}</div> |
| 54 | + </td> |
| 55 | + <td valign="top" style="padding:20px 20px 20px 14px;"> |
| 56 | + <div style="font-size:15px;font-weight:600;line-height:22px;padding-bottom:4px;">${escapeHtml(content.title)}</div> |
| 57 | + <div style="font-size:15px;line-height:22px;color:#14171a;white-space:pre-wrap;word-break:break-word;">${escapeHtml(content.body)}</div> |
| 58 | + </td> |
| 59 | + </tr> |
| 60 | + </table> |
| 61 | + </td></tr> |
| 62 | + <tr><td style="padding-bottom:28px;font-size:15px;"> |
| 63 | + <a href="${open}" style="color:#5b3fb1;text-decoration:underline;">Open thread →</a> |
| 64 | + </td></tr> |
| 65 | + <tr><td style="border-top:1px solid #e7e5e4;padding-top:20px;font-size:13px;line-height:20px;color:#6b7076;"> |
| 66 | + You received this email because ${escapeHtml(content.reason)}.<br /> |
| 67 | + <a href="${unsubscribe}" style="color:#6b7076;text-decoration:underline;">${escapeHtml(unsubscribeLabel)}</a> |
| 68 | + </td></tr> |
| 69 | + </table> |
| 70 | + </td></tr> |
| 71 | + </table> |
| 72 | + </body> |
| 73 | +</html>`; |
| 74 | + return { subject: content.subject, text, html }; |
| 75 | +}; |
| 76 | + |
| 77 | +/** |
| 78 | + * Email through the deployment's Cloudflare Email Sending sender. Available |
| 79 | + * whenever a sender is configured, whatever sign-in methods are offered. |
| 80 | + */ |
| 81 | +export const emailChannel = ( |
| 82 | + sender: EmailSender | undefined, |
| 83 | +): NotificationChannel => ({ |
| 84 | + id: "email", |
| 85 | + subscriptionTitle: (topicNoun) => |
| 86 | + `${capitalized(topicNoun)} notification emails`, |
| 87 | + subscriptionDescription: (trigger) => `Receive an email when ${trigger}.`, |
| 88 | + available: sender !== undefined, |
| 89 | + deliver: async (delivery) => { |
| 90 | + if (sender === undefined) throw new ChannelDeliveryError("unavailable"); |
| 91 | + try { |
| 92 | + await sender.binding.send({ |
| 93 | + from: senderAddress(sender), |
| 94 | + to: delivery.recipient.email, |
| 95 | + ...notificationEmail(delivery), |
| 96 | + headers: { |
| 97 | + "List-Unsubscribe": `<${delivery.links.unsubscribe}>`, |
| 98 | + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", |
| 99 | + }, |
| 100 | + }); |
| 101 | + } catch (cause) { |
| 102 | + throw new ChannelDeliveryError("email_send_failed", { cause }); |
| 103 | + } |
| 104 | + }, |
| 105 | +}); |
0 commit comments