import { escapeHtml } from "../lib/email-headers";
import type { TelegramEntity } from "./api";
import { TELEGRAM_MAX_MESSAGE_CHARS } from "./api";
// --- Telegram -> email -----------------------------------------------------
/**
* Telegram entity offsets are UTF-16 code units, which is exactly what a JS
* string index is — so plain `slice()` is correct here and iterating code points
* (`[...text]`, `Array.from`) would silently shift every offset after the first
* emoji or other non-BMP character. Do not "fix" the slicing below.
*/
type EntityNode = {
entity: TelegramEntity;
children: EntityNode[];
};
function buildEntityTree(entities: TelegramEntity[]): EntityNode[] {
const sorted = [...entities].sort((a, b) => a.offset - b.offset || b.length - a.length);
const roots: EntityNode[] = [];
const stack: EntityNode[] = [];
for (const entity of sorted) {
const end = entity.offset + entity.length;
while (stack.length > 0) {
const top = stack[stack.length - 1]!;
if (top.entity.offset + top.entity.length <= entity.offset) {
stack.pop();
} else {
break;
}
}
const node: EntityNode = { entity, children: [] };
const parent = stack[stack.length - 1];
if (parent && end <= parent.entity.offset + parent.entity.length) {
parent.children.push(node);
} else {
// Partially overlapping entities aren't representable as nested markup.
// Telegram doesn't emit them; if one shows up, treat it as top level.
stack.length = 0;
roots.push(node);
}
stack.push(node);
}
return roots;
}
/** Only web and mail schemes survive into outgoing mail. */
function safeHref(url: string): string | null {
const trimmed = url.trim();
return /^(https?:|mailto:)/i.test(trimmed) ? trimmed : null;
}
function wrapEntity(entity: TelegramEntity, inner: string, rawText: string): string {
switch (entity.type) {
case "bold":
return `${inner}`;
case "italic":
return `${inner}`;
case "underline":
return `${inner}`;
case "strikethrough":
return `${inner}`;
case "code":
return `${inner}`;
case "pre":
return `
${inner}
`;
case "blockquote":
case "expandable_blockquote":
return `${inner}
`;
case "text_link": {
const href = entity.url ? safeHref(entity.url) : null;
return href ? `${inner}` : inner;
}
case "url": {
const href = safeHref(rawText);
return href ? `${inner}` : inner;
}
case "email":
return `${inner}`;
default:
// spoiler, mention, hashtag, custom_emoji… have no email equivalent;
// their text still goes through, just unstyled.
return inner;
}
}
function renderRange(text: string, nodes: EntityNode[], start: number, end: number): string {
let out = "";
let cursor = start;
for (const node of nodes) {
const nodeStart = node.entity.offset;
const nodeEnd = nodeStart + node.entity.length;
if (nodeStart > cursor) {
out += escapeHtml(text.slice(cursor, nodeStart));
}
const inner = renderRange(text, node.children, nodeStart, nodeEnd);
out += wrapEntity(node.entity, inner, text.slice(nodeStart, nodeEnd));
cursor = nodeEnd;
}
if (cursor < end) {
out += escapeHtml(text.slice(cursor, end));
}
return out;
}
/** A Telegram message body as email-ready HTML paragraphs. */
export function entitiesToHtml(text: string, entities: TelegramEntity[] | undefined): string {
const rendered = renderRange(text, buildEntityTree(entities ?? []), 0, text.length);
return rendered
.split(/\n{2,}/)
.map((block) => `${block.replace(/\n/g, "
")}
`)
.join("\n");
}
// --- email -> Telegram -----------------------------------------------------
/** Telegram's HTML parse mode needs the same three characters escaped. */
export function telegramEscape(value: string): string {
return value.replace(/&/g, "&").replace(//g, ">");
}
/**
* Escape first, then clip — in that order.
*
* Escaping expands: one `&` becomes five characters. Clipping the raw text and
* escaping afterwards bounds the wrong string, so a subject full of `&` can still
* blow past Telegram's 4096-character limit and get the whole message rejected.
* The trailing-entity trim exists for the same reason: a message cut mid-`&`
* is invalid HTML and Telegram refuses it outright.
*/
function escapeAndClip(value: string, max: number): string {
const escaped = telegramEscape(value.trim());
if (escaped.length <= max) {
return escaped;
}
const cut = escaped.slice(0, Math.max(0, max - 1)).replace(/&[a-z]*$/i, "");
return `${cut}…`;
}
/**
* Last line of defense. If the assembled card is somehow still over the limit,
* send a short plain alternative rather than let Telegram reject it: a lost
* notification is worse than a terse one.
*/
function withinLimit(text: string, fallback: string): string {
return text.length <= TELEGRAM_MAX_MESSAGE_CHARS ? text : fallback;
}
/**
* What has happened to the email since the card announced it.
*
* A card with no status is a card about mail nobody has touched yet, which is
* exactly what "new" means. Everything else is a fact the operator already
* established on some surface, and a card that keeps claiming "pending" after it
* is a card that lies.
*/
export const CARD_STATUS_LABELS = {
archived: "archivado",
read: "leído",
unread: "sin leer",
trashed: "en la papelera",
inbox: "de vuelta en la bandeja",
replied: "respondido",
} as const;
export type TelegramCardStatus = keyof typeof CARD_STATUS_LABELS;
/** What the card can say about one attachment: enough to decide whether to fetch it. */
export type CardAttachment = {
filename: string | null;
size: number;
};
export type InboundNotification = {
fromAddr: string;
mailboxAddress: string;
subject: string | null;
snippet: string | null;
hasAttachments: boolean;
/**
* Names and sizes when they could be read from the mailbox, absent when they
* could not. The boolean above stays the fallback rather than being replaced by
* this: a Durable Object that did not answer must still produce a card saying
* the email has attachments.
*/
attachments?: CardAttachment[];
/**
* True when nobody at this deployment has ever heard from this sender. Cheap to
* compute and disproportionately useful on an address that receives cold mail:
* it separates "a stranger" from "the thread you are already in".
*/
firstContact?: boolean;
/** Absent on the first render; set when the card is restated after an action. */
status?: TelegramCardStatus | null;
};
const SUBJECT_LIMIT = 200;
const SNIPPET_LIMIT = 700;
const ADDRESS_LIMIT = 120;
const BODY_PREVIEW_LIMIT = 1500;
const FILENAME_LIMIT = 60;
/** Past this many names the list stops informing and starts filling the screen. */
const ATTACHMENT_NAMES_SHOWN = 4;
/**
* A size a human can judge a download by. Deliberately decimal units: mail
* clients, Telegram itself and every operator's intuition say a 240 KB PDF, and
* being right about kibibytes here would only make the number disagree with every
* other place the same file is shown.
*/
export function formatByteSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "?";
if (bytes < 1000) return `${Math.round(bytes)} B`;
const units = ["KB", "MB", "GB"];
let value = bytes / 1000;
let unit = 0;
while (value >= 1000 && unit < units.length - 1) {
value /= 1000;
unit += 1;
}
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
}
/**
* The attachment line.
*
* "📎 con adjuntos" told the operator that a decision existed without giving him
* anything to decide with — a signed contract and a tracking pixel produced the
* same six words. Names and sizes are what turn it into a judgement he can make
* from the card.
*/
function renderAttachmentLine(input: InboundNotification): string {
if (!input.attachments?.length) {
return input.hasAttachments ? "\n📎 con adjuntos" : "";
}
const shown = input.attachments
.slice(0, ATTACHMENT_NAMES_SHOWN)
.map(
(attachment) =>
`${escapeAndClip(attachment.filename?.trim() || "(sin nombre)", FILENAME_LIMIT)} (${formatByteSize(attachment.size)})`,
)
.join(" · ");
const rest = input.attachments.length - ATTACHMENT_NAMES_SHOWN;
return `\n📎 ${shown}${rest > 0 ? ` · +${rest}` : ""}`;
}
/**
* The new-mail card.
*
* Deliberately a preview, not the mail: sender, subject and snippet. Full bodies
* would put whatever arrives — password resets, 2FA codes — into a cloud chat
* that is not end-to-end encrypted. Reading the whole one is what the "Leer
* completo" button is for: still an explicit act, just one the operator can
* perform where he already is.
*
* Rendered from the same inputs every time, so restating a card after an action
* only ever changes the status line — an edit is not a chance to rewrite what the
* operator was originally told.
*/
export function renderInboundNotification(input: InboundNotification): string {
const from = escapeAndClip(input.fromAddr, ADDRESS_LIMIT);
const header = `📬 ${escapeAndClip(input.subject ?? "(sin asunto)", SUBJECT_LIMIT)}`;
const badge = input.firstContact ? " ✨" : "";
const addresses = `${from}${badge} → ${escapeAndClip(input.mailboxAddress, ADDRESS_LIMIT)}${
input.firstContact ? "\nprimer correo de este remitente" : ""
}`;
const attachments = renderAttachmentLine(input);
const status = input.status ? `\n· ${CARD_STATUS_LABELS[input.status]}` : "";
const footer = "\n\nResponde a este mensaje para contestar por email.";
const fixedLength =
header.length + addresses.length + attachments.length + status.length + footer.length + 4;
const budget = Math.max(0, Math.min(SNIPPET_LIMIT, TELEGRAM_MAX_MESSAGE_CHARS - fixedLength));
const snippet = input.snippet?.trim() ? `\n\n${escapeAndClip(input.snippet, budget)}` : "";
return withinLimit(
`${header}\n${addresses}${attachments}${status}${snippet}${footer}`,
`📬 Nuevo correo de ${from}${status}`,
);
}
/**
* A message body cut into pieces Telegram will accept, escaped for HTML mode.
*
* Escaping happens first and the cuts are measured on the escaped text, for the
* reason escapeAndClip explains: `&` becomes five characters, so a raw body that
* fits would produce a piece that does not. Cuts prefer a line break, then a
* space, and only then the hard limit — and a piece is never allowed to end
* mid-entity, because `&am` is invalid HTML and Telegram rejects the whole
* message rather than the character.
*/
export function chunkTelegramText(value: string, max = TELEGRAM_MAX_MESSAGE_CHARS): string[] {
const chunks: string[] = [];
let rest = telegramEscape(value);
while (rest.length > max) {
const window = rest.slice(0, max);
// +1 keeps the separator with the piece it ends, so nothing is dropped.
let cut = window.lastIndexOf("\n") + 1;
if (cut < max / 2) {
cut = window.lastIndexOf(" ") + 1;
}
if (cut < max / 2) {
cut = max;
}
const piece = rest.slice(0, cut);
const whole = piece.replace(/&[a-z]{0,6}$/i, "");
chunks.push(whole.length > 0 ? whole : piece);
rest = rest.slice(chunks[chunks.length - 1]!.length);
}
chunks.push(rest);
// Telegram refuses an empty message, and a body ending in blank lines would
// otherwise produce one.
return chunks.map((chunk) => chunk.trim()).filter((chunk) => chunk.length > 0);
}
/**
* The confirmation card shown before anything is actually sent.
*
* The version is not decoration. A correction arrives as a separate Telegram
* message and lands on a draft the operator cannot see, so without a visible
* counter the only difference between "your fix was applied" and "your fix went
* nowhere" is a preview that looks slightly different from what he remembers.
*/
export function renderDraftPreview(input: {
to: string[];
subject: string;
bodyText: string;
/** 1 (or absent) on the first render; bumped by every correction. */
version?: number;
}): string {
const to = escapeAndClip(input.to.join(", "), ADDRESS_LIMIT * 4);
const subject = escapeAndClip(input.subject, SUBJECT_LIMIT);
const body = escapeAndClip(input.bodyText, BODY_PREVIEW_LIMIT);
const version = input.version && input.version > 1 ? ` · v${input.version}` : "";
const header = `✉️ Borrador listo${version}`;
return withinLimit(
[header, `Para: ${to}`, `Asunto: ${subject}`, "", body].join("\n"),
`${header}\nPara: ${to}`,
);
}
/**
* The night, as one message.
*
* Sender and subject per line and nothing else: this is an index into mail the
* operator has not seen, not a second rendering of it, and the numbered buttons
* under it are what turn any line into the real card.
*/
export function renderNightDigest(input: {
total: number;
items: Array<{ fromAddr: string; subject: string | null }>;
}): string {
const heading = `🌙 ${input.total} ${input.total === 1 ? "correo retenido" : "correos retenidos"}`;
const lines = input.items.map(
(item, index) =>
`${index + 1}. ${escapeAndClip(item.fromAddr, ADDRESS_LIMIT)} — ${escapeAndClip(
item.subject ?? "(sin asunto)",
SUBJECT_LIMIT,
)}`,
);
const rest = input.total - input.items.length;
const overflow = rest > 0 ? [`… y ${rest} más, en la bandeja.`] : [];
const footer = ["", "Pulsa un número para abrir su tarjeta."];
return withinLimit(
[heading, "", ...lines, ...overflow, ...footer].join("\n"),
`${heading}\nPulsa /inbox para verlos.`,
);
}
/**
* The line under a page of retrieved cards.
*
* It exists to carry the pagination buttons, and says the range and the total so
* a page of four cards is legible as a slice of something bigger.
*/
export function renderResultFooter(input: {
query: string;
from: number;
to: number;
total: number;
}): string {
return withinLimit(
`🔎 ${escapeAndClip(input.query, SUBJECT_LIMIT)} · ${input.from}-${input.to} de ${input.total}`,
`🔎 ${input.from}-${input.to} de ${input.total}`,
);
}