fix(mail): stop Gmail refusing the search, and stop calling that "hittade inget" (#1521)
Pressing Leta produced mails=25, documents=0 on a real two-mailbox run. Nothing was found because nothing was searched: every request came back 429 "Too many concurrent requests for user". Two bugs, and the second is the one that matters. The search fanned out with Promise.all over every message id at once, one Gmail request per message, per connection. Gmail enforces a per-user concurrency ceiling as well as a daily quota, and this sailed past it long before any volume worth worrying about. It now runs through a pool of five per connection, which is comfortably under and still finishes a page of results in a couple of round trips. The catch turned each refusal into an empty array, with a comment saying one mailbox's failure must not become the company's. Right instinct, wrong consequence: an empty array is also what an empty mailbox returns, and the manual hunt loop stops on fetched === 0 because that is its signal for "the mailboxes hold nothing more for what is open". So a rate-limited search told the user their receipts do not exist, and stopped looking. searchFailureCount() now separates "could not look" from "nothing there". The run route reports it, and the loop treats a pass with failures as failed rather than finished, so pressing again is the obvious next move instead of a pointless one. This is the failure this feature exists to catch, happening inside the feature: silence that reads as an answer. Restoring the unbounded fan-out fails one test; removing the failure counter fails three. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Opus 5
parent
709c0c817a
commit
f2d9e98af3
@@ -104,6 +104,15 @@ export interface MailSearchService {
|
||||
* and must not outlive the run that read it, so the caller says when that is.
|
||||
*/
|
||||
releaseCache?(): void
|
||||
/**
|
||||
* How many connections refused the last search.
|
||||
*
|
||||
* A refused mailbox yields no candidates, exactly like an empty one. Without
|
||||
* a way to tell them apart, a run that Gmail rate-limited reports "found
|
||||
* nothing" and the caller stops looking, which is the worst possible answer:
|
||||
* it is wrong, and it sounds final.
|
||||
*/
|
||||
searchFailureCount?(): number
|
||||
}
|
||||
|
||||
class NoopMailSearchService implements MailSearchService {
|
||||
|
||||
@@ -128,6 +128,14 @@ export interface MailHuntSummary {
|
||||
withCandidates: number
|
||||
/** Receipts actually fetched and filed, ready for the amount match. */
|
||||
ingested: number
|
||||
/**
|
||||
* Connections that refused a search during this run.
|
||||
*
|
||||
* Non-zero means "we could not read a mailbox", which is not the same as
|
||||
* "the mailbox held nothing". The caller must not report the second when the
|
||||
* first happened, and must not treat the run as finished.
|
||||
*/
|
||||
searchFailures: number
|
||||
candidates: Array<{
|
||||
/** Merchant the model resolved the bank descriptor to. */
|
||||
merchant: string
|
||||
@@ -606,7 +614,7 @@ async function harvestReceiptsFromMail(
|
||||
maxMails: number,
|
||||
): Promise<MailHuntSummary> {
|
||||
const service = getMailSearchService()
|
||||
const summary: MailHuntSummary = { searched: 0, withCandidates: 0, ingested: 0, candidates: [] }
|
||||
const summary: MailHuntSummary = { searched: 0, withCandidates: 0, ingested: 0, searchFailures: 0, candidates: [] }
|
||||
if (!service.isConfigured() || purchases.length === 0) return summary
|
||||
|
||||
// Salary and tax runs are a company's largest outgoing rows, so without this
|
||||
@@ -639,6 +647,9 @@ async function harvestReceiptsFromMail(
|
||||
useDateWindow: false,
|
||||
limit: MAX_CANDIDATES_PER_MERCHANT,
|
||||
})
|
||||
// Accumulated across every merchant searched in this run: one refusal is
|
||||
// enough to make "found nothing" a lie.
|
||||
summary.searchFailures += service.searchFailureCount?.() ?? 0
|
||||
// The same mail answers several purchases from one supplier; it is read once.
|
||||
for (const c of found) {
|
||||
if (!byMessage.has(c.messageId)) byMessage.set(c.messageId, c)
|
||||
|
||||
Reference in New Issue
Block a user