Files
accounted/lib/mail-search/service.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

140 lines
4.8 KiB
TypeScript

/**
* Mail Search Service Interface
*
* Core defines the contract; the `mail` extension registers a real
* implementation (Gmail today, Microsoft Graph next). Without the extension a
* no-op service is used, so the receipt hunt degrades to matching whatever the
* company already holds in Underlag rather than failing.
*
* Mirrors lib/email/service.ts: core must never import from @/extensions, and
* a CI build with zero extensions has to compile.
*/
/** What the hunt knows about the purchase it is trying to find a receipt for. */
export interface MailSearchQuery {
/** Merchant tokens, already folded (see core-receipt-matcher). */
merchant: string | null
/** Charged amount, always positive. */
amount: number
currency: string
/** Purchase date, ISO. The window around it is the adapter's business. */
date: string
/**
* Merchant names likely to appear in the receipt itself, best first. Searched
* in place of the raw descriptor when present: the bank writes
* "ANTHROPIC* CLAUDE SUB", the receipt says "Anthropic".
*/
aliases?: string[]
/**
* Set false to search the whole mailbox regardless of date.
*
* A forwarded receipt is stamped when it was forwarded, which can be months
* after the purchase, so a window around the purchase date hides exactly the
* mail being looked for. Measured on a real mailbox: the same merchant search
* returned 0 hits inside the window and 10+ outside it.
*/
useDateWindow?: boolean
/** Only return messages carrying a file. */
requireAttachment?: boolean
/** Hits to return per mailbox. */
limit?: number
}
/**
* A message that might carry the receipt. Nothing is stored at this point: the
* hunt decides, and only a confirmed receipt is ever persisted.
*/
export interface MailCandidate {
connectionId: string
/** Mailbox the hit came from, so a proposal can say where it looked. */
mailbox: string
provider: 'gmail' | 'microsoft'
messageId: string
subject: string | null
from: string | null
receivedAt: string | null
/** Attachment ids on the message, resolvable through fetchAttachment. */
attachmentIds: string[]
/**
* Attachment filenames and the provider's own preview line. Metadata only,
* never stored: they are what lets the hunt tell a receipt from a newsletter
* that merely names the merchant, without opening anyone's mail.
*/
attachmentNames?: string[]
snippet?: string | null
/**
* Readable body text, already downloaded. A forwarded receipt writes the
* original sender and the original purchase date into its quoted header,
* which is the only reliable way to date a mail that was forwarded months
* later. Never stored: it is read once to extract fields and discarded.
*/
bodyText?: string | null
/**
* True when the message body IS the receipt (SL, Uber-style HTML mail) and
* there is no attachment to fetch. The caller renders it instead.
*/
bodyIsReceipt: boolean
}
export interface FetchedAttachment {
filename: string
mimeType: string
bytes: Buffer
}
export interface MailSearchService {
/**
* Search every healthy connection for one company. Read-only: the scopes
* requested cannot send, modify or delete, and nothing is written to the
* mailbox.
*/
search(companyId: string, query: MailSearchQuery): Promise<MailCandidate[]>
fetchAttachment(
connectionId: string,
messageId: string,
attachmentId: string,
): Promise<FetchedAttachment | null>
/** True when at least one provider has credentials configured. */
isConfigured(): boolean
/**
* Drop anything the adapter held for the duration of a hunt.
*
* Adapters cache messages so one mail is not downloaded once per purchase,
* and a cached message carries its body. Body text is read to extract fields
* 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 {
async search(): Promise<MailCandidate[]> {
return []
}
async fetchAttachment(): Promise<FetchedAttachment | null> {
return null
}
isConfigured(): boolean {
return false
}
releaseCache(): void {}
}
let mailSearchService: MailSearchService = new NoopMailSearchService()
export function getMailSearchService(): MailSearchService {
return mailSearchService
}
export function registerMailSearchService(svc: MailSearchService): void {
mailSearchService = svc
}