Scanning an inbox for what needs doing, without leaking the inbox

Most mail can be triaged on metadata alone and never reaches a model. What is left gets redacted before it is sent, checked with a residual scan, and stored as a derived fact rather than a message body.

A metadata triage drops most mail; survivors are redacted before reaching a model

Decide on metadata whether a message is worth reading at all, redact what little you then send, and store the conclusion rather than the message. In the demo below, one inbox message out of three has its body fetched, and the text that leaves the machine contains no address, no account number and no link.

The rest of this article is the four decisions that get you there, and the one that nearly went wrong.

The problem

"Read my email and tell me what needs doing" is a feature people want and a sentence that should worry you. The obvious implementation reads the mailbox, sends each message to a model, and asks what the action is.

That implementation has copied the mailbox to a third party. Not a summary of it — the mailbox. Salary discussions, a lawyer's letter, a two-factor code, the message from a doctor. Every one of those, in full, in someone else's request log.

It is also unnecessary. Most of what an inbox contains is not actionable and can be recognised as not actionable without reading a word of it.

A metadata triage drops most mail; survivors are redacted before reaching a model

Why the obvious approach fails

The instinct is to fix this with a policy: a data processing agreement, a retention promise, a "we don't train on your data" line in the terms. Those are worth having and they are not a control. They are a commitment about what somebody else does with data you already handed over.

The control is not sending it.

There is a second failure, less discussed. If you send whole messages, your audit story is "we sent the customer's mail to a vendor". If you send a redacted fragment, your audit story is a diff you can print. One of those survives a security questionnaire.

Ask for less at the OAuth screen

Start before any code. The scope you request is the ceiling on everything that follows, and the user sees it on the consent screen.

Scope What it covers
gmail.metadata Headers, labels, thread IDs. Not the body, not attachments.
gmail.readonly Everything readable, including bodies and attachments. No writes.
gmail.modify Read and write, except permanent delete.
https://mail.google.com/ Everything, including permanent delete.
Graph Mail.ReadBasic Microsoft's equivalent of metadata-only: mail properties without body or attachments.
Graph Mail.Read Full message content.

Two things follow from that table.

Metadata is often enough on its own. Sender, subject, thread depth, labels and the presence of a List-Unsubscribe header will classify a large share of a normal inbox without a body ever being fetched. A newsletter is a newsletter because of its headers, not its prose.

Never ask for write access to read. gmail.modify is convenient because it also lets you add a label, and it is the scope that turns a read-only bug into a mailbox-altering one. If you want to mark things, keep the marking in your own database.

Google classes most Gmail scopes as restricted, which means a production app goes through a review before it can serve users outside your own domain. That is friction worth planning for, and it is smaller for a metadata-only app. Scope semantics do change — check the provider's current documentation rather than this table before you ship.

Read-only is a hard boundary in the same way a grammar with no path to the host is: not a rule the code follows, a capability it does not have.

A local pre-filter, so most mail never reaches a model

This runs on metadata only. No body is fetched at this stage.

const ACTION_HINTS =
  /\b(invoice|payment|overdue|renew(?:al)?|sign|approve|deadline|due|contract)\b/i

export function triage(meta, knownDomains) {
  if (meta.headers['list-unsubscribe']) return { fetchBody: false, why: 'bulk mail header' }
  if (meta.labels.includes('CATEGORY_PROMOTIONS')) return { fetchBody: false, why: 'promotions' }
  const domain = (meta.from.split('@')[1] || '').toLowerCase()
  if (knownDomains.has(domain)) return { fetchBody: true, why: 'known counterparty' }
  if (ACTION_HINTS.test(meta.subject)) return { fetchBody: true, why: 'subject hint' }
  return { fetchBody: false, why: 'no signal in metadata' }
}

On the three-message inbox in the demo file:

  18f2a1  READ  known counterparty    Invoice BL-2291 is overdue
  18f2a2  skip  bulk mail header      Your weekly deals are here
  18f2a3  skip  no signal in metadata Re: lunch
  -> 1 of 3 bodies fetched at all

Two design notes. The known-counterparty list is the strongest signal you have and it costs nothing — a domain you have exchanged mail with before is far more likely to want something from you than one you have not. And the filter is allow-by-signal, not block-by-blocklist: the default answer is skip. A message about a medical result contains none of the action hints, is from a domain you have not invoiced, and is therefore never read. That is the correct outcome, and it is a consequence of the default rather than a rule someone remembered to write.

Redact, then check the redaction

The surviving message still has to be shortened before it leaves. A token map does that and stays local.

const PATTERNS = [
  ['EMAIL', /\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b/g],
  ['URL',   /\bhttps?:\/\/[^\s<>()"']+[^\s<>()"'.,;:]/g],
  ['IBAN',  /\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]){11,30}\b/g],
  ['CARD',  /\b(?:\d[ -]?){13,19}\b/g],
  ['PHONE', /(?:\+\d{1,3}[\s-]?)?(?:\(?\d{2,4}\)?[\s.-]?){2,4}\d{2,4}/g],
  ['MONEY', /(?:[$£€]|\b(?:USD|GBP|EUR|VND) ?)[\d,.]+/g],
]

export function redact(text) {
  const map = new Map(), seen = new Map(), counts = {}
  let out = text
  for (const [kind, re] of PATTERNS) {
    out = out.replace(re, (m) => {
      if (seen.has(m)) return seen.get(m)
      counts[kind] = (counts[kind] || 0) + 1
      const token = `[${kind}_${counts[kind]}]`
      seen.set(m, token); map.set(token, m); return token
    })
  }
  return { text: out, map }
}

export function restore(text, map) {
  return text.replace(/\[[A-Z]+_\d+\]/g, (tok) => map.get(tok) ?? tok)
}

Order matters. EMAIL runs before URL and URL before the numeric patterns, because otherwise the phone pattern claims part of an account number and the account number pattern claims part of a date. Reusing a token for a repeated value matters too — the same address mentioned twice should be the same token, or the model cannot tell that it is the same person.

Here is the part that earned its place in this article. The first version of that IBAN pattern was written the obvious way, as groups of four:

['IBAN', /\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]{4}){3,7}\b/g]

Running it produced this:

redacted: Please settle to IBAN [IBAN_1] 19 or pay online.

A UK IBAN is 22 characters. GB29 plus four groups of four is 20, and the last two digits fall outside the pattern and go out with the request. The redaction looked like it worked. It leaked the tail of an account number.

No regex is ever finished, so do not gate on the regex. Gate on a second, deliberately paranoid scan of the output, and refuse to send when it fires:

const RESIDUAL = [
  ['an @ sign',          /\S@\S/],
  ['6+ digits in a row', /(?:\d[ -]?){6,}/],
  ['a bare hostname',    /\b[\w-]+\.(?:com|net|org|vn|io)\b/i],
]

// A digit run left touching a token means a pattern matched only part of a value.
const TRUNCATED = /\[[A-Z]+_\d+\] ?(\d{1,5})\b/

export function residualScan(redacted) {
  const bare = redacted.replace(/\[[A-Z]+_\d+\]/g, '')   // ignore the tokens themselves
  const hits = RESIDUAL
    .map(([name, re]) => [name, bare.match(re)?.[0]])
    .filter(([, hit]) => hit)
  const t = redacted.match(TRUNCATED)
  if (t) hits.push(['a fragment beside a token', t[1]])
  return hits
}

That last check was not in the first draft of this article, and writing it is the reason the draft changed. The three general detectors, run against the buggy output, report clean. The leak was two digits, and 6+ digits in a row wants six. A scan tuned for whole unredacted values is blind to the tail of a value it thinks it already handled.

TRUNCATED is the check that fits the actual failure. A short digit run sitting immediately beside a token is not ordinary prose — it is the evidence that a pattern stopped early. Run both versions past it:

good IBAN pattern     clean
groups-of-four IBAN   BLOCKED: a fragment beside a token -> "19"

The residual scan is not cleverer than the redactor. It is stricter and dumber, which is the point: it will produce false positives, and a false positive costs you one message that goes to human review instead of a model. A false negative costs you an account number.

The generalisable lesson is not "add these four regexes". It is that a verification pass has to be aimed at how the thing under test actually breaks. Ours broke by truncating, so we needed a truncation detector, and we only knew that because we ran it and looked at the output rather than at the code.

Be honest about what it does not catch. In the demo output, "Hi Son" and the sign-off "Minh" survive redaction, because a name is not a pattern. If names matter for your threat model, that is a different tool — a named-entity pass, or sending only the subject line. Regexes give you certainty about the things that have shape, and nothing at all about the things that do not. The same division of labour runs through turning a business card into a CRM record.

Store the conclusion, not the message

The last leak is your own database. Having gone to the trouble of not sending the message anywhere, do not then keep a copy of it beside the answer.

export function derivedFact(meta, label, now) {
  return {
    message_id: meta.id,
    action: label,                                  // chosen from a fixed list
    counterparty_domain: meta.from.split('@')[1],
    observed_at: now.toISOString(),
    expires_at: new Date(now.getTime() + 90 * 864e5).toISOString(),
  }
}

export function sweep(facts, now) {
  return facts.filter((f) => new Date(f.expires_at) > now)
}

What is absent is the design. No body, no subject line, no sender address — a domain, because that is what you actually reason about, and a message ID so the user can be linked back to the real thing in their own mail client. Rendering "you have an invoice to pay" needs the row; showing the invoice needs the user's mailbox, which they already have.

action is a label from a fixed list rather than a sentence, which keeps the row small and keeps a model from writing free text into your database. That constraint deserves its own article, and has one: asking a model for JSON and actually getting JSON.

expires_at on the row itself is worth more than a deletion policy in a document, because a policy is a thing someone has to remember and a column is a thing a query can enforce:

DELETE FROM derived_facts WHERE expires_at < now();

Run it from the same worker that runs everything else — a Postgres job queue is a fine place for a sweep that must not be forgotten. And when a user disconnects their mailbox, delete their rows then, not at the next sweep.

Check it yourself

Every js block above concatenates, in order, into one file. Append this harness and you have scan.mjs — no network, no API key, no model call:

const known = new Set(['brightlane.vn'])
const inbox = [
  { id: '18f2a1', from: 'billing@brightlane.vn', subject: 'Invoice BL-2291 is overdue',
    headers: {}, labels: ['INBOX'],
    body: `Hi Son,\n\nInvoice BL-2291 for USD 4,250.00 is now 11 days overdue.\nPlease settle to IBAN GB29 NWBK 6016 1331 9268 19 or pay at\nhttps://pay.brightlane.vn/inv/BL-2291?token=9f3ac1.\n\nQuestions: minh.nguyen@brightlane.vn or +84 24 3936 1188.\n\nMinh` },
  { id: '18f2a2', from: 'news@deals.example.com', subject: 'Your weekly deals are here',
    headers: { 'list-unsubscribe': '<mailto:u@deals.example.com>' },
    labels: ['INBOX', 'CATEGORY_PROMOTIONS'], body: 'Save 40% this week only!' },
  { id: '18f2a3', from: 'anh@internal.example.org', subject: 'Re: lunch',
    headers: {}, labels: ['INBOX'], body: 'Thai place at 12:30?' },
]

console.log('=== 1. triage, on metadata only ===')
const toRead = []
for (const m of inbox) {
  const t = triage(m, known)
  if (t.fetchBody) toRead.push(m)
  console.log(`  ${m.id}  ${t.fetchBody ? 'READ' : 'skip'}  ${t.why.padEnd(21)} ${m.subject}`)
}
console.log(`  -> ${toRead.length} of ${inbox.length} bodies fetched at all\n`)

for (const m of toRead) {
  const { text, map } = redact(m.body)
  const residue = residualScan(text)
  console.log('=== 2. what would leave the machine ===')
  console.log(text.split('\n').map((l) => '  ' + l).join('\n'))
  console.log('\n=== 3. residual scan (fail closed) ===')
  if (residue.length) {
    for (const [n, h] of residue) console.log(`  BLOCKED: ${n} -> ${JSON.stringify(h)}`)
    continue
  }
  console.log('  clean\n')
  console.log('=== 4. the map, which never leaves ===')
  for (const [k, v] of map) console.log(`  ${k.padEnd(9)} ${v}`)
  console.log('\n=== 5. model output, rehydrated locally ===')
  console.log('  ' + restore('Pay [MONEY_1] to [IBAN_1]; queries to [EMAIL_1].', map))
  const fact = derivedFact(m, 'pay_invoice', new Date('2026-08-30T09:00:00Z'))
  console.log('\n=== 6. the row we store ===')
  console.log(JSON.stringify(fact, null, 2).split('\n').map((l) => '  ' + l).join('\n'))
  console.log('\n=== 7. retention sweep ===')
  console.log('  after 91 days:',
    sweep([fact], new Date('2026-11-30T09:00:00Z')).length, 'rows remain')
}
node scan.mjs

Real output, from Node 23.5.0:

=== 1. triage, on metadata only ===
  18f2a1  READ  known counterparty    Invoice BL-2291 is overdue
  18f2a2  skip  bulk mail header      Your weekly deals are here
  18f2a3  skip  no signal in metadata Re: lunch
  -> 1 of 3 bodies fetched at all

=== 2. what would leave the machine ===
  Hi Son,

  Invoice BL-2291 for [MONEY_1] is now 11 days overdue.
  Please settle to IBAN [IBAN_1] or pay at
  [URL_1].

  Questions: [EMAIL_1] or [PHONE_1].

  Minh

=== 3. residual scan (fail closed) ===
  clean

=== 4. the map, which never leaves ===
  [EMAIL_1] minh.nguyen@brightlane.vn
  [URL_1]   https://pay.brightlane.vn/inv/BL-2291?token=9f3ac1
  [IBAN_1]  GB29 NWBK 6016 1331 9268 19
  [PHONE_1] +84 24 3936 1188
  [MONEY_1] USD 4,250.00

=== 5. model output, rehydrated locally ===
  Pay USD 4,250.00 to GB29 NWBK 6016 1331 9268 19; queries to minh.nguyen@brightlane.vn.

=== 6. the row we store ===
  {
    "message_id": "18f2a1",
    "action": "pay_invoice",
    "counterparty_domain": "brightlane.vn",
    "observed_at": "2026-08-30T09:00:00.000Z",
    "expires_at": "2026-11-28T09:00:00.000Z"
  }

=== 7. retention sweep ===
  after 91 days: 0 rows remain

Then do the thing that makes the point. Swap the IBAN pattern for the groups-of-four version and run it again:

=== 2. what would leave the machine ===
  Please settle to IBAN [IBAN_1] 19 or pay at

=== 3. residual scan (fail closed) ===
  BLOCKED: a fragment beside a token -> "19"

Now delete the TRUNCATED check and run it a third time. Section 2 still leaks the 19, and section 3 goes back to reporting clean. That third run is the one worth doing, because it is what the first draft of this article did, and it is what a verification pass looks like when it is checking for the wrong thing.

Paste in one of your own emails while you are there. The first run finds something the patterns miss. It always does.

Where this goes next

This is how mailbox triage works in Workflow Builder — metadata first, a body fetched only when the metadata justifies it, and a redacted fragment where a whole message would have gone.

The two rules generalise past email. Ask for the narrowest capability the feature can be built on, and put a stupid, strict check downstream of the clever one — the same shape as refusing to evaluate user input as JavaScript.