* fix(mail): request gmail.readonly alone and read the mailbox address from Gmail's profile Google's restricted-scope review (2026-08-31) bounced the Gmail connector on a "scope discrepancy": the authorization URL asked for `openid email` on top of gmail.readonly, while the Cloud Console declares gmail.readonly only, and the review string-matches the two. The extra scopes existed solely to learn the mailbox address from the id_token. Gmail's users.getProfile returns that address under gmail.readonly, so the consent request now carries exactly one scope and the callback reads the address from the profile. Also adds `app_metadata.mfa_exempt === true` to shouldEnforceMfa. Google's reviewers log in with credentials we hand them and treat a second factor as an "authentication blocker"; app_metadata is service-role only, so this is an operator switch for demo accounts, never a user-reachable setting. Tests: scope pinned in google-oauth.test.ts, profile read in gmail-client.test.ts, callback path in oauth-callback.test.ts, flag shape in mfa.test.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UD3HsDX8hnJEqpt35azxBJ * fix(auth): time-box the reviewer MFA exemption instead of a boolean flag Superagent's P1 on the first shape was fair: a boolean app_metadata.mfa_exempt relied on someone remembering to clear it. The exemption is now app_metadata.mfa_exempt_until, an ISO timestamp honoured only while it lies in the future, so a forgotten flag dies on its own. Anything malformed or non-string enforces MFA. Still service-role only, still meant for the one demo account Google's OAuth reviewers log in with. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UD3HsDX8hnJEqpt35azxBJ --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
251 lines
8.4 KiB
TypeScript
251 lines
8.4 KiB
TypeScript
/**
|
|
* The thin slice of the Gmail API the hunt needs: search, read headers, fetch
|
|
* an attachment. Nothing here writes, because the granted scope cannot.
|
|
*/
|
|
import type { MailCandidate } from '@/lib/mail-search/service'
|
|
|
|
const API = 'https://gmail.googleapis.com/gmail/v1/users/me'
|
|
|
|
/**
|
|
* Deadline on every Gmail call.
|
|
*
|
|
* Mailboxes are searched with Promise.all, so one stalled request would hold
|
|
* the whole company's hunt open until the platform killed the run. A timeout
|
|
* turns that into one mailbox missing from tonight's sweep.
|
|
*/
|
|
export const GMAIL_TIMEOUT_MS = 15_000
|
|
|
|
/** Hits to consider per mailbox per purchase. */
|
|
export const MAX_RESULTS = 8
|
|
|
|
interface GmailHeader {
|
|
name: string
|
|
value: string
|
|
}
|
|
|
|
interface GmailPart {
|
|
filename?: string
|
|
mimeType?: string
|
|
body?: { attachmentId?: string; size?: number; data?: string }
|
|
parts?: GmailPart[]
|
|
}
|
|
|
|
/** Longest body worth carrying: a receipt states its total near the top. */
|
|
const MAX_BODY_CHARS = 2500
|
|
|
|
/**
|
|
* The readable text of a mail.
|
|
*
|
|
* Already on the wire (format=full is required to see the parts tree at all),
|
|
* so this costs nothing extra, and it is where the two facts a forwarded
|
|
* receipt hides live: the original sender and the original date, both written
|
|
* into the "Vidarebefordrat meddelande" header that Gmail's 200-character
|
|
* snippet cuts off.
|
|
*/
|
|
function collectBodyText(part: GmailPart | undefined, out: string[]): void {
|
|
if (!part) return
|
|
const type = part.mimeType ?? ''
|
|
if ((type === 'text/plain' || type === 'text/html') && part.body?.data) {
|
|
out.push(Buffer.from(part.body.data, 'base64url').toString('utf8'))
|
|
}
|
|
for (const child of part.parts ?? []) collectBodyText(child, out)
|
|
}
|
|
|
|
function readableBody(msg: GmailMessage): string {
|
|
const chunks: string[] = []
|
|
collectBodyText(msg.payload, chunks)
|
|
return chunks
|
|
.join('\n')
|
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/ |‌|͏/gi, ' ')
|
|
.replace(/&/gi, '&')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, MAX_BODY_CHARS)
|
|
}
|
|
|
|
interface GmailMessage {
|
|
id: string
|
|
internalDate?: string
|
|
snippet?: string
|
|
payload?: {
|
|
headers?: GmailHeader[]
|
|
filename?: string
|
|
mimeType?: string
|
|
body?: { attachmentId?: string; size?: number }
|
|
parts?: GmailPart[]
|
|
}
|
|
}
|
|
|
|
function header(msg: GmailMessage, name: string): string | null {
|
|
const found = msg.payload?.headers?.find((h) => h.name.toLowerCase() === name.toLowerCase())
|
|
return found?.value ?? null
|
|
}
|
|
|
|
/** Attachments anywhere in the MIME tree, ignoring inline images. */
|
|
function collectAttachments(
|
|
part: GmailPart | undefined,
|
|
out: Array<{ id: string; filename: string }>,
|
|
): void {
|
|
if (!part) return
|
|
const id = part.body?.attachmentId
|
|
const named = part.filename && part.filename.length > 0
|
|
const isDocument =
|
|
named &&
|
|
!/^image\/(png|gif)$/i.test(part.mimeType ?? '') // inline logos, not receipts
|
|
if (id && isDocument) out.push({ id, filename: part.filename as string })
|
|
for (const child of part.parts ?? []) collectAttachments(child, out)
|
|
}
|
|
|
|
async function gmailFetch<T>(accessToken: string, path: string): Promise<T> {
|
|
const response = await fetch(`${API}${path}`, {
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
signal: AbortSignal.timeout(GMAIL_TIMEOUT_MS),
|
|
})
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '')
|
|
throw new Error(`Gmail ${response.status}: ${text.slice(0, 200)}`)
|
|
}
|
|
return (await response.json()) as T
|
|
}
|
|
|
|
/**
|
|
* The address of the mailbox a token belongs to.
|
|
*
|
|
* Read from Gmail's own profile endpoint, which gmail.readonly covers, so the
|
|
* consent flow never has to ask for `openid email` on top. The address is the
|
|
* unique key of a connection: without it two grants for the same company
|
|
* could not be told apart.
|
|
*/
|
|
export async function getMailboxAddress(accessToken: string): Promise<string | null> {
|
|
const data = await gmailFetch<{ emailAddress?: string }>(accessToken, '/profile')
|
|
const address = data.emailAddress?.trim()
|
|
return address ? address : null
|
|
}
|
|
|
|
export async function searchMessageIds(
|
|
accessToken: string,
|
|
query: string,
|
|
maxResults: number = MAX_RESULTS,
|
|
): Promise<string[]> {
|
|
const params = new URLSearchParams({ q: query, maxResults: String(maxResults) })
|
|
const data = await gmailFetch<{ messages?: Array<{ id: string }> }>(
|
|
accessToken,
|
|
`/messages?${params.toString()}`,
|
|
)
|
|
return (data.messages ?? []).map((m) => m.id)
|
|
}
|
|
|
|
/**
|
|
* Messages already read, keyed by connection and id.
|
|
*
|
|
* A press searches many purchases across every mailbox, and one receipt mail
|
|
* answers several of those queries, so the same message was being downloaded
|
|
* dozens of times: 25 purchases against 2 mailboxes could ask for well over a
|
|
* thousand fetches to end up with a hundred distinct mails. Deduplication
|
|
* happened afterwards, which was too late to save the work.
|
|
*
|
|
* A mail's content never changes, so caching it needs no invalidation. The cap
|
|
* is what keeps a long-lived server from growing without bound.
|
|
*/
|
|
const summaryCache = new Map<string, MailCandidate>()
|
|
const SUMMARY_CACHE_MAX = 1_000
|
|
|
|
/**
|
|
* Subject, sender, date and which parts are attachments.
|
|
*
|
|
* `format=full` rather than `format=metadata`: metadata returns headers only
|
|
* and omits `payload.parts` entirely, so every message came back looking like
|
|
* it had no attachments and the hunt could never file anything. Gmail offers no
|
|
* format that returns the MIME structure without the body, so the body does
|
|
* come down the wire here. It is read for nothing and stored nowhere: only
|
|
* attachment bytes are ever persisted, and only after a match.
|
|
*/
|
|
export async function getMessageSummary(
|
|
accessToken: string,
|
|
messageId: string,
|
|
connectionId: string,
|
|
mailbox: string,
|
|
): Promise<MailCandidate> {
|
|
const cacheKey = `${connectionId}::${messageId}`
|
|
const cached = summaryCache.get(cacheKey)
|
|
if (cached) return cached
|
|
|
|
const msg = await gmailFetch<GmailMessage>(accessToken, `/messages/${messageId}?format=full`)
|
|
const attachments: Array<{ id: string; filename: string }> = []
|
|
collectAttachments(msg.payload, attachments)
|
|
|
|
const candidate: MailCandidate = {
|
|
connectionId,
|
|
mailbox,
|
|
provider: 'gmail',
|
|
messageId: msg.id,
|
|
subject: header(msg, 'Subject'),
|
|
from: header(msg, 'From'),
|
|
receivedAt: msg.internalDate
|
|
? new Date(Number(msg.internalDate)).toISOString()
|
|
: header(msg, 'Date'),
|
|
// A receipt with no attachment is usually the mail body itself; the caller
|
|
// decides whether to render it.
|
|
attachmentIds: attachments.map((a) => a.id),
|
|
attachmentNames: attachments.map((a) => a.filename),
|
|
snippet: msg.snippet ?? null,
|
|
bodyText: readableBody(msg),
|
|
bodyIsReceipt: attachments.length === 0,
|
|
}
|
|
|
|
// Oldest out first: the working set of one press is what matters, and a
|
|
// press that overflows the cap was going to refetch anyway.
|
|
if (summaryCache.size >= SUMMARY_CACHE_MAX) {
|
|
const oldest = summaryCache.keys().next().value
|
|
if (oldest) summaryCache.delete(oldest)
|
|
}
|
|
summaryCache.set(cacheKey, candidate)
|
|
return candidate
|
|
}
|
|
|
|
/** Drop everything read so far. Tests reuse message ids; production does not. */
|
|
export function clearMessageCache(): void {
|
|
summaryCache.clear()
|
|
}
|
|
|
|
export async function fetchAttachmentBytes(
|
|
accessToken: string,
|
|
messageId: string,
|
|
attachmentId: string,
|
|
): Promise<Buffer | null> {
|
|
const data = await gmailFetch<{ data?: string; size?: number }>(
|
|
accessToken,
|
|
`/messages/${messageId}/attachments/${attachmentId}`,
|
|
)
|
|
if (!data.data) return null
|
|
return Buffer.from(data.data, 'base64url')
|
|
}
|
|
|
|
/**
|
|
* Filename and MIME type live on the message, not on the attachment response,
|
|
* so they are read back from the parts tree.
|
|
*/
|
|
export async function describeAttachment(
|
|
accessToken: string,
|
|
messageId: string,
|
|
attachmentId: string,
|
|
): Promise<{ filename: string; mimeType: string } | null> {
|
|
const msg = await gmailFetch<GmailMessage>(accessToken, `/messages/${messageId}?format=full`)
|
|
let found: { filename: string; mimeType: string } | null = null
|
|
const walk = (part: GmailPart | undefined): void => {
|
|
if (!part || found) return
|
|
if (part.body?.attachmentId === attachmentId) {
|
|
found = {
|
|
filename: part.filename || 'underlag.pdf',
|
|
mimeType: part.mimeType || 'application/octet-stream',
|
|
}
|
|
return
|
|
}
|
|
for (const child of part.parts ?? []) walk(child)
|
|
}
|
|
walk(msg.payload)
|
|
return found
|
|
}
|