mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

inbox.ts

Language TypeScript18.1 KB396 lines

config/inbox.ts

  1. /**
  2. * The consolidated inbox: which mailboxes, who may read them, and which senders
  3. * are marketing.
  4. *
  5. * Most roster addresses are role mailboxes, so when one of them (`accounts@`,
  6. * say) changes hands, nobody holds a view across the others. This tab is that
  7. * view: marketing hidden into a collapsed bucket, one button to reclassify a
  8. * sender. Credentials are per-mailbox refresh tokens or one service account
  9. * with domain-wide delegation; see credentialFor() in the Worker.
  10. *
  11. * Reading and sending are two different grants. Reading uses
  12. * `gmail.readonly`, and the marketing filter is a dashboard-side suppression:
  13. * a sender wrongly filtered costs a click to undo and never moves a real
  14. * message in anyone's real inbox.
  15. *
  16. * Sending is the exception, and the only one (rule 12). It uses `gmail.send`,
  17. * it is gated by INBOX_SENDERS below (a separate list from INBOX_READERS), and
  18. * it cannot be undone by any click on this board. See `sendableMailboxes`.
  19. */
  20. import type { Viewer } from '../apps/dashboard/src/viewer.js'
  21. export interface Mailbox {
  22. /** The address, lowercased. Matches config/meridian-people.ts. */
  23. email: string
  24. /** Whose mailbox, for the row label. */
  25. label: string
  26. /**
  27. * The Worker secret holding this mailbox's OAuth refresh token. Named, not
  28. * derived, so renaming a label cannot silently point at the wrong secret.
  29. * A mailbox with no token set falls back to the service account, if that is
  30. * configured; see credentialFor() in the Worker.
  31. */
  32. secret: string
  33. }
  34. /**
  35. * Which mailboxes the inbox tab reads, and nothing else on this board.
  36. *
  37. * This list is deliberately not the roster: the client-facing role accounts
  38. * plus support, service and EA, and not projects@, frontdesk@ or nora.l@.
  39. * Removing a mailbox here removes it from the Inbox tab
  40. * only; the person keeps their My Day, their briefing and their roster row,
  41. * because those are driven by config/meridian-people.ts and this file never touches
  42. * them.
  43. */
  44. export const INBOX_MAILBOXES: Mailbox[] = [
  45. { email: 'hello@meridiandisplay.example', label: 'Sam Whitaker', secret: 'GMAIL_REFRESH_HELLO' },
  46. { email: 'orderdesk@meridiandisplay.example', label: 'Rafael Montero', secret: 'GMAIL_REFRESH_ORDERDESK' },
  47. { email: 'studio@meridiandisplay.example', label: 'Marco Ibáñez', secret: 'GMAIL_REFRESH_STUDIO' },
  48. // Labelled with its current holder. It reads 'Success' in any stretch where
  49. // nobody holds the desk.
  50. { email: 'accounts@meridiandisplay.example', label: 'Rosa Galván', secret: 'GMAIL_REFRESH_ACCOUNTS' },
  51. { email: 'help@meridiandisplay.example', label: 'Support', secret: 'GMAIL_REFRESH_HELP' },
  52. { email: 'service@meridiandisplay.example', label: 'Service', secret: 'GMAIL_REFRESH_SERVICE' },
  53. { email: 'exec@meridiandisplay.example', label: 'EA', secret: 'GMAIL_REFRESH_EXEC' },
  54. ]
  55. /**
  56. * Who may open the inbox tab, and whose mail they may see.
  57. *
  58. * This is other people's mail (a far larger disclosure than anything else on
  59. * this board), so it fails closed: a viewer with no entry gets the same
  60. * `denied()` from `/inbox` as from `/payload.json`, and the tab is absent from
  61. * the page rather than present and broken. An access list guessed on
  62. * someone's behalf is the kind of default that gets asserted and forgotten
  63. * (rule 13), and the cost of getting it wrong here is somebody reading a
  64. * colleague's mail.
  65. *
  66. * To grant: add an entry keyed by the reader's roster email. `mailboxes: 'all'`
  67. * means every mailbox in INBOX_MAILBOXES; a list means exactly those.
  68. *
  69. * 'hello@meridiandisplay.example': { mailboxes: 'all' },
  70. * 'studio@meridiandisplay.example': { mailboxes: ['studio@meridiandisplay.example'] },
  71. */
  72. export interface InboxGrant {
  73. mailboxes: 'all' | string[]
  74. }
  75. export const INBOX_READERS: Record<string, InboxGrant> = {
  76. // The whole roster, all seven mailboxes: on a team this size everyone
  77. // already sees each other's mail. Each entry is still an explicit
  78. // human-authorized line, not a loop over the roster: a new hire does not get
  79. // the inbox by being added to meridian-people.ts, only by a line here
  80. // (rule 13).
  81. 'hello@meridiandisplay.example': { mailboxes: 'all' }, // Sam Whitaker
  82. 'accounts@meridiandisplay.example': { mailboxes: 'all' }, // Rosa Galván
  83. 'studio@meridiandisplay.example': { mailboxes: 'all' }, // Marco Ibáñez
  84. 'orderdesk@meridiandisplay.example': { mailboxes: 'all' }, // Rafael Montero
  85. 'projects@meridiandisplay.example': { mailboxes: 'all' }, // Ines Carvalho
  86. 'frontdesk@meridiandisplay.example': { mailboxes: 'all' }, // Theo Bassett
  87. 'nora.l@meridiandisplay.example': { mailboxes: 'all' }, // Nora Lindqvist
  88. // Two shared desks, not roster people. They hold send grants below, and a
  89. // send grant without a read grant is inert (/inbox refuses the whole block
  90. // before any Reply button is rendered), so both lists carry the line.
  91. //
  92. // Different in kind from the seven above: whoever can sign in as one can
  93. // read all seven mailboxes and write to a client as any of them, and they
  94. // resolve to the company viewer everywhere else on the board (no personal
  95. // briefing, no Leadership) because they are not in meridian-people.ts.
  96. 'help@meridiandisplay.example': { mailboxes: 'all' }, // Support desk
  97. 'service@meridiandisplay.example': { mailboxes: 'all' }, // Service desk
  98. }
  99. /** Mailboxes this viewer may read. Empty means no access, and no tab. */
  100. export function readableMailboxes(viewer: Viewer): string[] {
  101. const key = (viewer.email ?? '').trim().toLowerCase()
  102. if (!key) return []
  103. const grant = INBOX_READERS[key]
  104. if (!grant) return []
  105. const all = INBOX_MAILBOXES.map((m) => m.email)
  106. if (grant.mailboxes === 'all') return all
  107. // Intersected with the real list, so a typo in a grant cannot invent a mailbox.
  108. return grant.mailboxes.map((m) => m.trim().toLowerCase()).filter((m) => all.includes(m))
  109. }
  110. export function canReadInbox(viewer: Viewer): boolean {
  111. return readableMailboxes(viewer).length > 0
  112. }
  113. /**
  114. * Who may send, and as which mailbox.
  115. *
  116. * This is not INBOX_READERS and must never be derived from it. Reading a
  117. * colleague's mail is a disclosure; sending from `accounts@` is speaking to a
  118. * client in someone else's name, to someone outside the company, permanently.
  119. * Those are different decisions and they get different lists, which is why
  120. * `readableMailboxes()` is not consulted on any send route, and why a test
  121. * pins that a viewer who may read everything and holds no line here is refused.
  122. *
  123. * The lines below:
  124. * - `hello@`, `frontdesk@`, `accounts@`, `help@` and `service@` may send as
  125. * any of the seven;
  126. * - the two designers may send only from their own mailbox;
  127. * - the people who do not work client mail get no line.
  128. *
  129. * Four of the five full grants are role mailboxes, and those follow the
  130. * address, not the person. Whoever signs in as `accounts@` inherits every one of
  131. * these grants without a line being written, which is exactly what "follows
  132. * the address" means. `help@` and `service@` are shared desks with no
  133. * single holder at all.
  134. * Whoever can sign in as one of them can write to a client as any of the seven,
  135. * on the day their account exists, with no further edit here. That is
  136. * intended (a desk answers clients and has to answer from wherever the client
  137. * wrote), and it is written down because these are the lines whose meaning
  138. * changes while this file stays the same. A handoff is the moment to re-read
  139. * them; nothing else will prompt it.
  140. *
  141. * A person with no line here sees no Reply button at all, and the open-message
  142. * page says why rather than showing a control that fails (rule 9). Adding
  143. * someone is a human edit to this file; a new hire does not get the power to
  144. * write to clients by being added to meridian-people.ts (rule 13).
  145. */
  146. export interface SendGrant {
  147. /** 'all' means every mailbox in INBOX_MAILBOXES; a list means exactly those. */
  148. from: 'all' | string[]
  149. }
  150. export const INBOX_SENDERS: Record<string, SendGrant> = {
  151. 'hello@meridiandisplay.example': { from: 'all' }, // Sam Whitaker
  152. 'frontdesk@meridiandisplay.example': { from: 'all' }, // Theo Bassett
  153. // Client success answers from any inbox. This grant follows the address:
  154. // it reads as the rep's grant only because they sign in as accounts@, and
  155. // the day a rep signs in under their own address, this line grants them
  156. // nothing and they need their own (rule 13).
  157. 'accounts@meridiandisplay.example': { from: 'all' }, // Client Success: the desk, and its holder
  158. 'help@meridiandisplay.example': { from: 'all' }, // Support: the desk, not the holder
  159. 'service@meridiandisplay.example': { from: 'all' }, // Service: the desk, not the holder
  160. 'studio@meridiandisplay.example': { from: ['studio@meridiandisplay.example'] }, // Marco Ibáñez: his own only
  161. 'orderdesk@meridiandisplay.example': { from: ['orderdesk@meridiandisplay.example'] }, // Rafael Montero: his own only
  162. }
  163. /**
  164. * Mailboxes this viewer may send from. Empty means no Reply button anywhere.
  165. *
  166. * Same normalization and the same intersection with the real mailbox list as
  167. * readableMailboxes (a typo in a grant cannot invent a mailbox), and
  168. * deliberately the same shape, so the two can be read side by side and seen to
  169. * be independent.
  170. */
  171. export function sendableMailboxes(viewer: Viewer): string[] {
  172. const key = (viewer.email ?? '').trim().toLowerCase()
  173. if (!key) return []
  174. const grant = INBOX_SENDERS[key]
  175. if (!grant) return []
  176. const all = INBOX_MAILBOXES.map((m) => m.email)
  177. if (grant.from === 'all') return all
  178. return grant.from.map((m) => m.trim().toLowerCase()).filter((m) => all.includes(m))
  179. }
  180. /** The check every send and signature-write route makes. Nothing else will do. */
  181. export function canSendFrom(viewer: Viewer, mailbox: string): boolean {
  182. return sendableMailboxes(viewer).includes(mailbox.trim().toLowerCase())
  183. }
  184. /**
  185. * How many people one send may reach.
  186. *
  187. * Reply-all and compose can both name several recipients, so the ceiling is a
  188. * number, and it is small on purpose: this board is where a client thread gets
  189. * answered, not where an announcement gets sent. Fifteen covers a real client
  190. * thread with room for a long Cc list.
  191. *
  192. * Over the cap is refused with a count, not trimmed to fit. Dropping the
  193. * sixteenth recipient silently would send a reply-all that omits someone the
  194. * sender watched themselves include, and the omission would be invisible on
  195. * this board and in the Sent folder both.
  196. */
  197. export const RECIPIENT_CAP = 15
  198. /**
  199. * What "small attachments" means, in bytes. The derivation is written down so
  200. * the numbers do not look arbitrary.
  201. *
  202. * The binding constraint is the double base64 on the way out. `sendMessage`
  203. * puts the whole RFC 2822 message in Gmail's JSON `raw` field, and the
  204. * attachment is base64 inside that message (4/3) before the message itself is
  205. * base64url'd into the field (4/3 again), so a byte of attachment costs about
  206. * 1.78 bytes of request body. The plain JSON send endpoint is good for about
  207. * 10 MB, which puts the ceiling near 5.6 MB of files; 4 MB total leaves the
  208. * body around 7.2 MB with the text, the signature and the headers.
  209. *
  210. * Raising these past ~5 MB is not a matter of editing the number: it needs the
  211. * `uploadType=multipart` upload endpoint, which takes the MIME body directly
  212. * and is good for 35 MB. That is a different request shape in send.ts, and the
  213. * cap is here rather than there so the next person finds this note first.
  214. *
  215. * The count exists so five 800 KB files cannot pass the per-file check and
  216. * blow the total, and so a mis-click on a folder of 200 files is refused
  217. * before anything is read.
  218. *
  219. * Nothing is silently dropped at these limits. Over the cap, the compose page
  220. * refuses the whole submission and says which file and by how much (rule 5's
  221. * other half: a truncated set of attachments would be a message the sender
  222. * believes carried a file it did not).
  223. */
  224. export const ATTACHMENT_LIMITS = {
  225. /** Per file, bytes. */
  226. perFile: 3 * 1024 * 1024,
  227. /** All files on one message, bytes. */
  228. total: 4 * 1024 * 1024,
  229. /** How many files on one message. */
  230. count: 5,
  231. } as const
  232. /**
  233. * Senders whose mail is marketing.
  234. *
  235. * Seeded from a live inbox over a 60-day window, then corrected by hand.
  236. * Gmail's own `category:promotions` was the starting point (the source's
  237. * own semantics beat a list someone invented, rule 10), but it is a seed and
  238. * not an authority, for two measured reasons:
  239. *
  240. * It sweeps in things nobody wants hidden. `msa@communication.microsoft.com`
  241. * sends terms-of-service changes. `TrackingUpdates@courier.vendor.example`,
  242. * `failed-payments@stripe.com`, `no-reply@fathom.video` and
  243. * `notifications@ninety.io` all land in a Gmail category and are all
  244. * operationally load-bearing; two of them are sources this very board reads.
  245. *
  246. * It misses cold outreach sitting in Primary. `rep@growth.vendor.example`
  247. * and `outreach@salestool.example` are sales email in the main inbox.
  248. *
  249. * So the seed is short and conservative. Everything else arrives as a one-click
  250. * addition, which is what the button is for. A list that hides a real email is
  251. * far more expensive than a list that misses a newsletter.
  252. */
  253. export const MARKETING_SENDERS: string[] = [
  254. 'events@send.flowhook.vendor.example',
  255. 'feedback@imagegen.vendor.example',
  256. 'fred@notetaker.vendor.example',
  257. 'licenses@enjoy.antivirus.vendor.example',
  258. 'news@send.flowhook.vendor.example',
  259. 'news@upscaler.vendor.example',
  260. 'noreply@qrcodes.vendor.example',
  261. 'outreach@salestool.example',
  262. 'productupdates@send.scheduler.vendor.example',
  263. 'rep@growth.vendor.example',
  264. 'support@updates.noisecancel.vendor.example',
  265. 'sweeps@message.courier.vendor.example',
  266. 'team@mail.gridbase.vendor.example',
  267. 'teamscheduler@send.scheduler.vendor.example',
  268. ]
  269. /**
  270. * Operational senders. These cannot be filtered, by anyone, ever.
  271. *
  272. * Flagged by exact address rather than domain, and enforced rather than
  273. * documented: `matchesFilter` refuses to hide anything on this list and the
  274. * POST route refuses to add to it. The tab shows these rows
  275. * with "operational" where the filter button would be.
  276. *
  277. * Enforced rather than trusted, because one misdirected click on
  278. * `failed-payments@stripe.com` hides money that did not arrive, and nobody finds
  279. * out by noticing; they find out by not noticing. A protection that depends on
  280. * everyone always being careful is not a protection. Filtering is otherwise
  281. * one-click reversible; this is the short list where the cost of being wrong is
  282. * too high to leave to a click.
  283. *
  284. * Do not add a domain rule for these. Vendors send both kinds of mail from
  285. * different addresses on the same domain, and a domain rule would hide the half
  286. * that matters:
  287. *
  288. * sweeps@message.courier.vendor.example marketing
  289. * TrackingUpdates@courier.vendor.example where every shipment is
  290. *
  291. * teamscheduler@send.scheduler.vendor.example marketing
  292. * notifications@scheduler.vendor.example someone booked a meeting
  293. *
  294. * The same holds across a pair that shares no domain: cold outreach such as
  295. * `rep@growth.vendor.example` is filtered while `failed-payments@stripe.com`,
  296. * money that did not arrive, never is. That pair is tested alongside these two.
  297. *
  298. * This is why `matchesFilter` does exact addresses and explicit `@domain.com`
  299. * entries and nothing in between. A substring or same-domain rule would hide a
  300. * failed payment.
  301. */
  302. export const KNOWN_NOT_MARKETING: string[] = [
  303. 'trackingupdates@courier.vendor.example',
  304. 'failed-payments@stripe.com',
  305. 'notifications@scheduler.vendor.example',
  306. 'no-reply@fathom.video',
  307. 'notifications@ninety.io',
  308. 'msa@communication.microsoft.com',
  309. 'calendar-notification@google.com',
  310. 'drive-shares-dm-noreply@google.com',
  311. 'do-not-reply@meridiandisplay.example',
  312. ]
  313. /** Is this sender protected from ever being filtered? */
  314. export function isOperational(sender: string): boolean {
  315. return KNOWN_NOT_MARKETING.includes(sender.trim().toLowerCase())
  316. }
  317. /**
  318. * Does this sender match the filter list?
  319. *
  320. * Exact normalized address, or an explicit `@domain.com` entry. Nothing fuzzy:
  321. * see KNOWN_NOT_MARKETING for the collisions that rule out anything looser.
  322. *
  323. * An operational sender returns false whatever the list says, so even a filter
  324. * entry added by hand to config, or left in KV from before a sender was
  325. * protected, cannot hide it.
  326. */
  327. export function matchesFilter(sender: string, list: string[]): boolean {
  328. const s = sender.trim().toLowerCase()
  329. if (!s) return false
  330. if (isOperational(s)) return false
  331. const at = s.lastIndexOf('@')
  332. const domain = at > 0 ? s.slice(at) : ''
  333. for (const raw of list) {
  334. const entry = raw.trim().toLowerCase()
  335. if (!entry) continue
  336. if (entry.startsWith('@')) {
  337. if (domain === entry) return true
  338. } else if (entry === s) {
  339. return true
  340. }
  341. }
  342. return false
  343. }
  344. /**
  345. * Live search: what a typed query is allowed to reach.
  346. *
  347. * The 30-day window holds several times what the board keeps, so the board
  348. * keeps the recent slice for browsing and the search box goes to Gmail:
  349. * everything is findable even though only some of it is pre-loaded.
  350. *
  351. * Two scope decisions, both deliberate:
  352. *
  353. * It drops the 30-day window. That is the point: the footer says "older
  354. * mail is in Gmail, not gone", and search is how the board reaches it.
  355. *
  356. * It stays in the inbox. Archived mail, Sent, Spam and Trash are outside what
  357. * this board has ever shown, and a search box is not where that should
  358. * quietly change. `in:inbox` is forced on every query and a user-typed `in:`
  359. * is removed rather than combined, because `in:inbox in:anywhere` is Gmail's
  360. * syntax for widening and a reader typing it would not know they had.
  361. *
  362. * Everything else passes through, so `from:`, `has:attachment`, quoted phrases
  363. * and date operators all work. That is a feature and the page says so: these
  364. * are the searcher's own words in Gmail's own language, not a second query
  365. * language invented here.
  366. */
  367. export const SEARCH_QUERY_CAP = 200
  368. export function buildSearchQuery(raw: string): string {
  369. const cleaned = raw
  370. .slice(0, SEARCH_QUERY_CAP)
  371. // Strip scope operators the board does not offer. Case-insensitive, and
  372. // only when they lead a term, so the word "in" and an address containing
  373. // "label:" inside quotes are untouched.
  374. .replace(/(^|\s)-?(in|label):\S*/gi, '$1')
  375. .trim()
  376. return cleaned ? `in:inbox ${cleaned}` : 'in:inbox'
  377. }