/** * Pure email layer (Phase 9). * * Two jobs: * 1. Parse the SocialCalc `sendemail ` command * that arrives via `execute` / `triggerActionCell`. Legacy * src/sc.ls:139-142 split on spaces and replaced `%20` with real * spaces inside each of the three fields. * * 2. Expose an `EmailSender` interface with two concrete impls: * - `StubEmailSender` — in-process, returns the literal * `" [E-mail Sent]"` string legacy produced (src/emailer.ls:33 * and `:51`). Used in tests + when no Cloudflare binding is * available. * - `BindingEmailSender` — wraps Cloudflare Email Service's structured * Workers binding (`from`/`to`/`subject`/`text`). * * Nothing here touches `env.*` directly: routes/DO code builds an * `EmailSender` and injects it. Keeps the module pure + 100% Node * coverable. */ /** * Parsed `sendemail` payload. Fields are already `%20`-decoded per * legacy (src/sc.ls:142 `.replace(/%20/g, ' ')`). */ export interface SendemailParsed { readonly to: string; readonly subject: string; readonly body: string; } /** * Strip characters that would let an attacker-controlled cell value break * out of an RFC822 header line: CR, LF, and the NUL/control bytes that * could fold a header or smuggle a second one (Cc/Bcc/Content-Type) or an * arbitrary MIME body. The `to`/`subject` fields flow straight into header * lines (`To: …`, `Subject: …`) and into the Cloudflare `send_email` * binding's `to`, so both are sanitized at the boundary. * * Benign inputs (no control bytes) are returned unchanged, so this is a * no-op for every real payload — it only neutralizes injection attempts. */ export function stripHeaderInjection(value: string): string { // eslint-disable-next-line no-control-regex return value.replace(/[\x00-\x1f\x7f]/g, ''); } /** * Parse a raw SocialCalc command of the form * sendemail * * Returns `null` for non-matching input. `%20` is decoded back to * spaces in every field (legacy stored the values URL-encoded so the * space-delimited command wasn't ambiguous at parse time). * * Legacy splits on a single space per token and only ever consumes * three tokens total — any trailing tokens are dropped silently. We * mirror that exactly so the shape of the recorded fixtures holds. */ export function parseSendemail(cmdstr: string): SendemailParsed | null { if (typeof cmdstr !== 'string') return null; const trimmed = cmdstr.trim(); if (trimmed.length === 0) return null; // Legacy `.split(' ')` splits on a SINGLE space (not whitespace runs); // we keep that to avoid breaking unusual payloads that rely on // consecutive spaces collapsing into empty tokens. const parts = trimmed.split(' '); if (parts.length < 4) return null; if (parts[0] !== 'sendemail') return null; const decode = (s: string): string => s.replace(/%20/g, ' '); return { // `to`/`subject` become RFC822 header lines downstream — strip any // literal CR/LF/control bytes so an attacker-controlled cell can't // inject extra headers (Cc/Bcc/Content-Type) or a second MIME part. to: stripHeaderInjection(decode(parts[1]!)), subject: stripHeaderInjection(decode(parts[2]!)), // Join trailing tokens back so a body that happened to contain a // literal space (encoded as %20) survives intact. Legacy only // indexed [3], but in practice the client always URL-encoded so a // single token held the full body. We preserve that by default // while being tolerant of trailing tokens. body: decode(parts.slice(3).join(' ')), }; } /** * Shape of `{message}` returned from a successful send. Legacy wrapped * the string in a `confirmemailsent` WS reply (src/sc.ls:249-253). */ export interface SendResult { readonly message: string; } /** * EmailSender interface — exposes a single `send` method. Callers * don't know or care whether the underlying transport is a stub or a * Cloudflare binding; the RoomDO handler just invokes it and forwards * the `message` onto the `confirmemailsent` WS broadcast. */ export interface EmailSender { send(to: string, subject: string, body: string): Promise; } /** Legacy-matching string used by both `StubEmailSender` and the * binding-success path. Kept as a named constant so tests can pin it. */ export const STUB_SENT_MESSAGE = ' [E-mail Sent]'; /** * Message reported when a `sendemail` fires on a deployment that has no * `send_email` binding configured. The legacy stub claimed `" [E-mail * Sent]"` here too, which was a *false* confirmation — the mail never * left the worker. We surface the honest state instead so neither the * `confirmemailsent` WS broadcast nor any operator log can mislead. */ export const EMAIL_DISABLED_MESSAGE = ' [E-mail not sent: email is not configured]'; /** Stable client-facing transport failure without provider/internal details. */ export const EMAIL_FAILURE_MESSAGE = ' [E-mail not sent]'; /** * In-process email transport. Returns the legacy success string for * every call. Never rejects. Used as a "successful send" test double and * to pin the legacy `BindingEmailSender` success message. */ export class StubEmailSender implements EmailSender { // eslint-disable-next-line @typescript-eslint/no-unused-vars async send(_to: string, _subject: string, _body: string): Promise { return { message: STUB_SENT_MESSAGE }; } } /** * Fallback transport when no Cloudflare `send_email` binding is bound * (the default deploy — `[[send_email]]` is commented out in * wrangler.toml). Sends nothing and reports `EMAIL_DISABLED_MESSAGE` so * the client/operator is told the truth rather than a fake "E-mail * Sent". Never rejects. */ export class DisabledEmailSender implements EmailSender { // eslint-disable-next-line @typescript-eslint/no-unused-vars async send(_to: string, _subject: string, _body: string): Promise { return { message: EMAIL_DISABLED_MESSAGE }; } } /** * Minimal structural shape of Cloudflare Email Service's Workers binding. * The recommended structured API avoids the legacy `EmailMessage` raw-MIME * constructor and follows the current `{from: {email}, to, subject, text}` API. */ export interface SendEmailBinding { send(message: { readonly from: { readonly email: string; readonly name?: string }; readonly to: string; readonly subject: string; readonly text: string; }): Promise; } /** * Wraps an `env.EMAIL` Cloudflare Email Service binding. Header fields are * sanitized again at this final boundary; the cell body is sent as plain * text. Transport failures stay generic because this result is broadcast to * every room client and must not expose provider or deployment internals. */ export class BindingEmailSender implements EmailSender { readonly #binding: SendEmailBinding; readonly #fromAddress: string; constructor(binding: SendEmailBinding, fromAddress: string) { this.#binding = binding; this.#fromAddress = fromAddress; } async send(to: string, subject: string, body: string): Promise { try { await this.#binding.send({ from: { email: stripHeaderInjection(this.#fromAddress) }, to: stripHeaderInjection(to), subject: stripHeaderInjection(subject), text: body, }); return { message: STUB_SENT_MESSAGE }; } catch { return { message: EMAIL_FAILURE_MESSAGE }; } } }