inbox.ts
Language TypeScript18.1 KB396 lines
/*** The consolidated inbox: which mailboxes, who may read them, and which senders* are marketing.** Most roster addresses are role mailboxes, so when one of them (`accounts@`,* say) changes hands, nobody holds a view across the others. This tab is that* view: marketing hidden into a collapsed bucket, one button to reclassify a* sender. Credentials are per-mailbox refresh tokens or one service account* with domain-wide delegation; see credentialFor() in the Worker.** Reading and sending are two different grants. Reading uses* `gmail.readonly`, and the marketing filter is a dashboard-side suppression:* a sender wrongly filtered costs a click to undo and never moves a real* message in anyone's real inbox.** Sending is the exception, and the only one (rule 12). It uses `gmail.send`,* it is gated by INBOX_SENDERS below (a separate list from INBOX_READERS), and* it cannot be undone by any click on this board. See `sendableMailboxes`.*/import type { Viewer } from '../apps/dashboard/src/viewer.js'export interface Mailbox {/** The address, lowercased. Matches config/meridian-people.ts. */email: string/** Whose mailbox, for the row label. */label: string/*** The Worker secret holding this mailbox's OAuth refresh token. Named, not* derived, so renaming a label cannot silently point at the wrong secret.* A mailbox with no token set falls back to the service account, if that is* configured; see credentialFor() in the Worker.*/secret: string}/*** Which mailboxes the inbox tab reads, and nothing else on this board.** This list is deliberately not the roster: the client-facing role accounts* plus support, service and EA, and not projects@, frontdesk@ or nora.l@.* Removing a mailbox here removes it from the Inbox tab* only; the person keeps their My Day, their briefing and their roster row,* because those are driven by config/meridian-people.ts and this file never touches* them.*/export const INBOX_MAILBOXES: Mailbox[] = [{ email: 'hello@meridiandisplay.example', label: 'Sam Whitaker', secret: 'GMAIL_REFRESH_HELLO' },{ email: 'orderdesk@meridiandisplay.example', label: 'Rafael Montero', secret: 'GMAIL_REFRESH_ORDERDESK' },{ email: 'studio@meridiandisplay.example', label: 'Marco Ibáñez', secret: 'GMAIL_REFRESH_STUDIO' },// Labelled with its current holder. It reads 'Success' in any stretch where// nobody holds the desk.{ email: 'accounts@meridiandisplay.example', label: 'Rosa Galván', secret: 'GMAIL_REFRESH_ACCOUNTS' },{ email: 'help@meridiandisplay.example', label: 'Support', secret: 'GMAIL_REFRESH_HELP' },{ email: 'service@meridiandisplay.example', label: 'Service', secret: 'GMAIL_REFRESH_SERVICE' },{ email: 'exec@meridiandisplay.example', label: 'EA', secret: 'GMAIL_REFRESH_EXEC' },]/*** Who may open the inbox tab, and whose mail they may see.** This is other people's mail (a far larger disclosure than anything else on* this board), so it fails closed: a viewer with no entry gets the same* `denied()` from `/inbox` as from `/payload.json`, and the tab is absent from* the page rather than present and broken. An access list guessed on* someone's behalf is the kind of default that gets asserted and forgotten* (rule 13), and the cost of getting it wrong here is somebody reading a* colleague's mail.** To grant: add an entry keyed by the reader's roster email. `mailboxes: 'all'`* means every mailbox in INBOX_MAILBOXES; a list means exactly those.** 'hello@meridiandisplay.example': { mailboxes: 'all' },* 'studio@meridiandisplay.example': { mailboxes: ['studio@meridiandisplay.example'] },*/export interface InboxGrant {mailboxes: 'all' | string[]}export const INBOX_READERS: Record<string, InboxGrant> = {// The whole roster, all seven mailboxes: on a team this size everyone// already sees each other's mail. Each entry is still an explicit// human-authorized line, not a loop over the roster: a new hire does not get// the inbox by being added to meridian-people.ts, only by a line here// (rule 13).'hello@meridiandisplay.example': { mailboxes: 'all' }, // Sam Whitaker'accounts@meridiandisplay.example': { mailboxes: 'all' }, // Rosa Galván'studio@meridiandisplay.example': { mailboxes: 'all' }, // Marco Ibáñez'orderdesk@meridiandisplay.example': { mailboxes: 'all' }, // Rafael Montero'projects@meridiandisplay.example': { mailboxes: 'all' }, // Ines Carvalho'frontdesk@meridiandisplay.example': { mailboxes: 'all' }, // Theo Bassett'nora.l@meridiandisplay.example': { mailboxes: 'all' }, // Nora Lindqvist// Two shared desks, not roster people. They hold send grants below, and a// send grant without a read grant is inert (/inbox refuses the whole block// before any Reply button is rendered), so both lists carry the line.//// Different in kind from the seven above: whoever can sign in as one can// read all seven mailboxes and write to a client as any of them, and they// resolve to the company viewer everywhere else on the board (no personal// briefing, no Leadership) because they are not in meridian-people.ts.'help@meridiandisplay.example': { mailboxes: 'all' }, // Support desk'service@meridiandisplay.example': { mailboxes: 'all' }, // Service desk}/** Mailboxes this viewer may read. Empty means no access, and no tab. */export function readableMailboxes(viewer: Viewer): string[] {const key = (viewer.email ?? '').trim().toLowerCase()if (!key) return []const grant = INBOX_READERS[key]if (!grant) return []const all = INBOX_MAILBOXES.map((m) => m.email)if (grant.mailboxes === 'all') return all// Intersected with the real list, so a typo in a grant cannot invent a mailbox.return grant.mailboxes.map((m) => m.trim().toLowerCase()).filter((m) => all.includes(m))}export function canReadInbox(viewer: Viewer): boolean {return readableMailboxes(viewer).length > 0}/*** Who may send, and as which mailbox.** This is not INBOX_READERS and must never be derived from it. Reading a* colleague's mail is a disclosure; sending from `accounts@` is speaking to a* client in someone else's name, to someone outside the company, permanently.* Those are different decisions and they get different lists, which is why* `readableMailboxes()` is not consulted on any send route, and why a test* pins that a viewer who may read everything and holds no line here is refused.** The lines below:* - `hello@`, `frontdesk@`, `accounts@`, `help@` and `service@` may send as* any of the seven;* - the two designers may send only from their own mailbox;* - the people who do not work client mail get no line.** Four of the five full grants are role mailboxes, and those follow the* address, not the person. Whoever signs in as `accounts@` inherits every one of* these grants without a line being written, which is exactly what "follows* the address" means. `help@` and `service@` are shared desks with no* single holder at all.* Whoever can sign in as one of them can write to a client as any of the seven,* on the day their account exists, with no further edit here. That is* intended (a desk answers clients and has to answer from wherever the client* wrote), and it is written down because these are the lines whose meaning* changes while this file stays the same. A handoff is the moment to re-read* them; nothing else will prompt it.** A person with no line here sees no Reply button at all, and the open-message* page says why rather than showing a control that fails (rule 9). Adding* someone is a human edit to this file; a new hire does not get the power to* write to clients by being added to meridian-people.ts (rule 13).*/export interface SendGrant {/** 'all' means every mailbox in INBOX_MAILBOXES; a list means exactly those. */from: 'all' | string[]}export const INBOX_SENDERS: Record<string, SendGrant> = {'hello@meridiandisplay.example': { from: 'all' }, // Sam Whitaker'frontdesk@meridiandisplay.example': { from: 'all' }, // Theo Bassett// Client success answers from any inbox. This grant follows the address:// it reads as the rep's grant only because they sign in as accounts@, and// the day a rep signs in under their own address, this line grants them// nothing and they need their own (rule 13).'accounts@meridiandisplay.example': { from: 'all' }, // Client Success: the desk, and its holder'help@meridiandisplay.example': { from: 'all' }, // Support: the desk, not the holder'service@meridiandisplay.example': { from: 'all' }, // Service: the desk, not the holder'studio@meridiandisplay.example': { from: ['studio@meridiandisplay.example'] }, // Marco Ibáñez: his own only'orderdesk@meridiandisplay.example': { from: ['orderdesk@meridiandisplay.example'] }, // Rafael Montero: his own only}/*** Mailboxes this viewer may send from. Empty means no Reply button anywhere.** Same normalization and the same intersection with the real mailbox list as* readableMailboxes (a typo in a grant cannot invent a mailbox), and* deliberately the same shape, so the two can be read side by side and seen to* be independent.*/export function sendableMailboxes(viewer: Viewer): string[] {const key = (viewer.email ?? '').trim().toLowerCase()if (!key) return []const grant = INBOX_SENDERS[key]if (!grant) return []const all = INBOX_MAILBOXES.map((m) => m.email)if (grant.from === 'all') return allreturn grant.from.map((m) => m.trim().toLowerCase()).filter((m) => all.includes(m))}/** The check every send and signature-write route makes. Nothing else will do. */export function canSendFrom(viewer: Viewer, mailbox: string): boolean {return sendableMailboxes(viewer).includes(mailbox.trim().toLowerCase())}/*** How many people one send may reach.** Reply-all and compose can both name several recipients, so the ceiling is a* number, and it is small on purpose: this board is where a client thread gets* answered, not where an announcement gets sent. Fifteen covers a real client* thread with room for a long Cc list.** Over the cap is refused with a count, not trimmed to fit. Dropping the* sixteenth recipient silently would send a reply-all that omits someone the* sender watched themselves include, and the omission would be invisible on* this board and in the Sent folder both.*/export const RECIPIENT_CAP = 15/*** What "small attachments" means, in bytes. The derivation is written down so* the numbers do not look arbitrary.** The binding constraint is the double base64 on the way out. `sendMessage`* puts the whole RFC 2822 message in Gmail's JSON `raw` field, and the* attachment is base64 inside that message (4/3) before the message itself is* base64url'd into the field (4/3 again), so a byte of attachment costs about* 1.78 bytes of request body. The plain JSON send endpoint is good for about* 10 MB, which puts the ceiling near 5.6 MB of files; 4 MB total leaves the* body around 7.2 MB with the text, the signature and the headers.** Raising these past ~5 MB is not a matter of editing the number: it needs the* `uploadType=multipart` upload endpoint, which takes the MIME body directly* and is good for 35 MB. That is a different request shape in send.ts, and the* cap is here rather than there so the next person finds this note first.** The count exists so five 800 KB files cannot pass the per-file check and* blow the total, and so a mis-click on a folder of 200 files is refused* before anything is read.** Nothing is silently dropped at these limits. Over the cap, the compose page* refuses the whole submission and says which file and by how much (rule 5's* other half: a truncated set of attachments would be a message the sender* believes carried a file it did not).*/export const ATTACHMENT_LIMITS = {/** Per file, bytes. */perFile: 3 * 1024 * 1024,/** All files on one message, bytes. */total: 4 * 1024 * 1024,/** How many files on one message. */count: 5,} as const/*** Senders whose mail is marketing.** Seeded from a live inbox over a 60-day window, then corrected by hand.* Gmail's own `category:promotions` was the starting point (the source's* own semantics beat a list someone invented, rule 10), but it is a seed and* not an authority, for two measured reasons:** It sweeps in things nobody wants hidden. `msa@communication.microsoft.com`* sends terms-of-service changes. `TrackingUpdates@courier.vendor.example`,* `failed-payments@stripe.com`, `no-reply@fathom.video` and* `notifications@ninety.io` all land in a Gmail category and are all* operationally load-bearing; two of them are sources this very board reads.** It misses cold outreach sitting in Primary. `rep@growth.vendor.example`* and `outreach@salestool.example` are sales email in the main inbox.** So the seed is short and conservative. Everything else arrives as a one-click* addition, which is what the button is for. A list that hides a real email is* far more expensive than a list that misses a newsletter.*/export const MARKETING_SENDERS: string[] = ['events@send.flowhook.vendor.example','feedback@imagegen.vendor.example','fred@notetaker.vendor.example','licenses@enjoy.antivirus.vendor.example','news@send.flowhook.vendor.example','news@upscaler.vendor.example','noreply@qrcodes.vendor.example','outreach@salestool.example','productupdates@send.scheduler.vendor.example','rep@growth.vendor.example','support@updates.noisecancel.vendor.example','sweeps@message.courier.vendor.example','team@mail.gridbase.vendor.example','teamscheduler@send.scheduler.vendor.example',]/*** Operational senders. These cannot be filtered, by anyone, ever.** Flagged by exact address rather than domain, and enforced rather than* documented: `matchesFilter` refuses to hide anything on this list and the* POST route refuses to add to it. The tab shows these rows* with "operational" where the filter button would be.** Enforced rather than trusted, because one misdirected click on* `failed-payments@stripe.com` hides money that did not arrive, and nobody finds* out by noticing; they find out by not noticing. A protection that depends on* everyone always being careful is not a protection. Filtering is otherwise* one-click reversible; this is the short list where the cost of being wrong is* too high to leave to a click.** Do not add a domain rule for these. Vendors send both kinds of mail from* different addresses on the same domain, and a domain rule would hide the half* that matters:** sweeps@message.courier.vendor.example marketing* TrackingUpdates@courier.vendor.example where every shipment is** teamscheduler@send.scheduler.vendor.example marketing* notifications@scheduler.vendor.example someone booked a meeting** The same holds across a pair that shares no domain: cold outreach such as* `rep@growth.vendor.example` is filtered while `failed-payments@stripe.com`,* money that did not arrive, never is. That pair is tested alongside these two.** This is why `matchesFilter` does exact addresses and explicit `@domain.com`* entries and nothing in between. A substring or same-domain rule would hide a* failed payment.*/export const KNOWN_NOT_MARKETING: string[] = ['trackingupdates@courier.vendor.example','failed-payments@stripe.com','notifications@scheduler.vendor.example','no-reply@fathom.video','notifications@ninety.io','msa@communication.microsoft.com','calendar-notification@google.com','drive-shares-dm-noreply@google.com','do-not-reply@meridiandisplay.example',]/** Is this sender protected from ever being filtered? */export function isOperational(sender: string): boolean {return KNOWN_NOT_MARKETING.includes(sender.trim().toLowerCase())}/*** Does this sender match the filter list?** Exact normalized address, or an explicit `@domain.com` entry. Nothing fuzzy:* see KNOWN_NOT_MARKETING for the collisions that rule out anything looser.** An operational sender returns false whatever the list says, so even a filter* entry added by hand to config, or left in KV from before a sender was* protected, cannot hide it.*/export function matchesFilter(sender: string, list: string[]): boolean {const s = sender.trim().toLowerCase()if (!s) return falseif (isOperational(s)) return falseconst at = s.lastIndexOf('@')const domain = at > 0 ? s.slice(at) : ''for (const raw of list) {const entry = raw.trim().toLowerCase()if (!entry) continueif (entry.startsWith('@')) {if (domain === entry) return true} else if (entry === s) {return true}}return false}/*** Live search: what a typed query is allowed to reach.** The 30-day window holds several times what the board keeps, so the board* keeps the recent slice for browsing and the search box goes to Gmail:* everything is findable even though only some of it is pre-loaded.** Two scope decisions, both deliberate:** It drops the 30-day window. That is the point: the footer says "older* mail is in Gmail, not gone", and search is how the board reaches it.** It stays in the inbox. Archived mail, Sent, Spam and Trash are outside what* this board has ever shown, and a search box is not where that should* quietly change. `in:inbox` is forced on every query and a user-typed `in:`* is removed rather than combined, because `in:inbox in:anywhere` is Gmail's* syntax for widening and a reader typing it would not know they had.** Everything else passes through, so `from:`, `has:attachment`, quoted phrases* and date operators all work. That is a feature and the page says so: these* are the searcher's own words in Gmail's own language, not a second query* language invented here.*/export const SEARCH_QUERY_CAP = 200export function buildSearchQuery(raw: string): string {const cleaned = raw.slice(0, SEARCH_QUERY_CAP)// Strip scope operators the board does not offer. Case-insensitive, and// only when they lead a term, so the word "in" and an address containing// "label:" inside quotes are untouched..replace(/(^|\s)-?(in|label):\S*/gi, '$1').trim()return cleaned ? `in:inbox ${cleaned}` : 'in:inbox'}