diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e9732fb..8f06258e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,9 @@ jobs: - name: Type check run: npm run typecheck + - name: Strict No-PII Check + run: npm run check:pii + autofix: needs: build-and-test if: > diff --git a/.gitignore b/.gitignore index e2934f18..7fba69fb 100644 --- a/.gitignore +++ b/.gitignore @@ -403,5 +403,6 @@ playwright-report/ # compliance-ci-trigger # ci-trigger-298 .dev-lead/ +.pii-rules.json # compliance-ci-trigger # ci-trigger-298 diff --git a/.husky/pre-commit b/.husky/pre-commit index a8637945..1fba98df 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,5 @@ #!/bin/sh set -e +node scripts/check-no-pii.js --staged npx lint-staged npm run typecheck diff --git a/docs/proposals/proposal-001-ai-gemini-semantic-classifier.md b/docs/proposals/proposal-001-ai-gemini-semantic-classifier.md index aeaeb556..19a87888 100644 --- a/docs/proposals/proposal-001-ai-gemini-semantic-classifier.md +++ b/docs/proposals/proposal-001-ai-gemini-semantic-classifier.md @@ -92,15 +92,15 @@ The `Gmail.Users.Settings.Filters.create` call additionally requires the **Advan The classifier operates against a standardized, mutually exclusive, collectively exhaustive (MECE) 7-domain taxonomy: -| 7 Canonical Domains | Standard PARA Area | Scope & Contents | -| :---------------------------- | :---------------------------- | :------------------------------------------------------------------------------------- | -| **`01_Household`** | Real Estate & Living Space | Primary residence, secondary properties, rental properties, property archives | -| **`02_Finance_Legal`** | Financial & Legal Protections | Taxes, banking, insurance policies, court records, custody, estate planning | -| **`03_Vehicles`** | Transportation & Assets | Vehicle searches, vehicle titles, maintenance logs | -| **`04_Family_Health_School`** | Family Welfare & Health | Children, school records, medical appointments, therapy sessions, educational programs | -| **`05_Tech_Infrastructure`** | IT & Home Automation | Home automation, NAS backups, scripting tools, network gear, software licenses | -| **`06_Work_Career`** | Professional Life & Income | Employment records, resumes, interview materials, expense reports, admin notes | -| **`07_Community_NonProfit`** | Civic Duty & Service | Charity organizations, community board roles, faith outreach | +| 7 Canonical Domains | Standard PARA Area | Scope & Contents | +| :--------------------------- | :---------------------------- | :------------------------------------------------------------------------------------- | +| **`01_Household`** | Real Estate & Living Space | Primary residence, secondary properties, rental properties, property archives | +| **`02_Finance_Legal`** | Financial & Legal Protections | Taxes, banking, insurance policies, court records, custody, estate planning | +| **`03_Vehicles`** | Transportation & Assets | Vehicle searches, vehicle titles, maintenance logs | +| **`04_Family_Health`** | Family Welfare & Health | Children, school records, medical appointments, therapy sessions, educational programs | +| **`05_Tech_Infrastructure`** | IT & Home Automation | Home automation, NAS backups, scripting tools, network gear, software licenses | +| **`06_Work_Career`** | Professional Life & Income | Employment records, resumes, interview materials, expense reports, admin notes | +| **`07_Community_NonProfit`** | Civic Duty & Service | Charity organizations, community board roles, faith outreach | --- @@ -137,7 +137,7 @@ When Gemini classifies a new sender with high confidence (>= 95%), the script au **Confidence calibration and staged approval:** - Only classifications with `confidence >= autoFilterConfidenceThreshold` (default: `0.95`) trigger filter creation. -- Senders categorized under sensitive domains (e.g., `02_Finance_Legal`, `04_Family_Health_School`) should be reviewed manually before trusting auto-generated filters in production. +- Senders categorized under sensitive domains (e.g., `02_Finance_Legal`, `04_Family_Health`) should be reviewed manually before trusting auto-generated filters in production. **Duplicate filter detection:** @@ -188,6 +188,6 @@ src/gmail-ai-classifier/ ## Discussion & Tracking -- Issue Tracked: [#491](https://github.com/open-source-projects/google-app-scripts/issues/491) -- Pull Request Tracked: [#492](https://github.com/open-source-projects/google-app-scripts/pull/492) -- Repository: `open-source-projects/google-app-scripts` +- Issue Tracked: [#491](https://github.com/petry-projects/google-app-scripts/issues/491) +- Pull Request Tracked: [#492](https://github.com/petry-projects/google-app-scripts/pull/492) +- Repository: `petry-projects/google-app-scripts` diff --git a/package.json b/package.json index e8d6f125..69a32491 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "lint": "eslint src", "format": "prettier --write .", "typecheck": "tsc --noEmit", - "check": "prettier --check . && npm run lint", + "check:pii": "node scripts/check-no-pii.js", + "check": "prettier --check . && npm run lint && npm run check:pii", "prepare": "husky" }, "lint-staged": { diff --git a/scripts/check-no-pii.js b/scripts/check-no-pii.js new file mode 100644 index 00000000..6184a3b3 --- /dev/null +++ b/scripts/check-no-pii.js @@ -0,0 +1,321 @@ +#!/usr/bin/env node +// pii-scanner-definition + +/** + * Strict No-PII Scanner for petry-projects/google-app-scripts + * Ensures that open-source code, docs, and tests remain completely generic + * and free of personal household identities, addresses, and private businesses. + * + * Additional household-specific rules can be supplied out-of-band via: + * - `PII_CUSTOM_RULES_JSON` environment variable (JSON array of { name, pattern, flags?, description, whitelist? }) + * - `.pii-rules.json` in the repository root (gitignored) + * + * Usage: + * node scripts/check-no-pii.js # Scans repository files from repo root + * node scripts/check-no-pii.js --staged # Scans only git-staged index blobs + */ + +const fs = require('fs') +const path = require('path') +const { execFileSync } = require('child_process') + +const REPO_ROOT = path.resolve(__dirname, '..') + +// Base PII patterns with human-readable descriptions +const BASE_PROHIBITED_RULES = [ + { + name: 'Household Surnames', + pattern: /\b(Petry)\b/gi, + description: + 'Household surname found outside of allowed open-source organizational context.', + whitelist: [ + /petry-projects/i, + /github\.com\/don-petry/i, + /github\.com\/petry-projects/i, + /Copyright \(c\) \d{4} Don Petry/i, + /Don Petry \u2013/i, + /Petry-Projects \u2013/i, + ], + }, + { + name: 'Personal Gmail Addresses', + pattern: /\b[a-zA-Z0-9._%+-]+@gmail\.com\b/gi, + description: + 'Personal Gmail addresses must not be in open-source files; use generic placeholders (e.g. user@example.com).', + whitelist: [ + /^household-member@gmail\.com$/i, + /^your-email@gmail\.com$/i, + /^primary@gmail\.com$/i, + /^testuser@gmail\.com$/i, + ], + }, + { + name: 'US Social Security Numbers', + pattern: /\b\d{3}-\d{2}-\d{4}\b/g, + description: 'Social Security Numbers must never be committed.', + whitelist: [/^000-00-0000$/, /^123-45-6789$/], + }, +] + +function loadCustomRules() { + const customRules = [] + let rawJson = process.env.PII_CUSTOM_RULES_JSON || '' + if (!rawJson) { + const localRulesPath = path.join(REPO_ROOT, '.pii-rules.json') + if (fs.existsSync(localRulesPath)) { + rawJson = fs.readFileSync(localRulesPath, 'utf8') + } + } + if (!rawJson) return customRules + + try { + const parsed = JSON.parse(rawJson) + if (Array.isArray(parsed)) { + for (const entry of parsed) { + if (entry && entry.name && entry.pattern) { + const flags = entry.flags + ? entry.flags.includes('g') + ? entry.flags + : `${entry.flags}g` + : 'gi' + customRules.push({ + name: entry.name, + pattern: new RegExp(entry.pattern, flags), + description: + entry.description || + 'Prohibited custom PII pattern matched in repository.', + whitelist: Array.isArray(entry.whitelist) + ? entry.whitelist.map((w) => new RegExp(w, 'i')) + : undefined, + }) + } + } + } + } catch (err) { + console.error('Error parsing custom PII rules:', err.message) + process.exit(1) + } + return customRules +} + +const PROHIBITED_RULES = [...BASE_PROHIBITED_RULES, ...loadCustomRules()] + +// Extensions and directories to scan +const SCANNABLE_EXTENSIONS = new Set([ + '.js', + '.ts', + '.gs', + '.json', + '.md', + '.yml', + '.yaml', + '.html', +]) + +const IGNORED_DIRS = new Set([ + 'node_modules', + '.git', + 'coverage', + 'dist', + 'build', + '.wt-', +]) + +function toPosixPath(p) { + return p.split(path.sep).join('/') +} + +function isIgnoredPath(relPath) { + const posixPath = toPosixPath(relPath) + const parts = posixPath.split('/') + for (const part of parts) { + if (IGNORED_DIRS.has(part) || part.startsWith('.wt-')) { + return true + } + } + if ( + posixPath === 'scripts/check-no-pii.js' || + posixPath === '.pii-rules.json' + ) { + return true + } + return false +} + +function getStagedFiles() { + try { + const output = execFileSync( + 'git', + ['diff', '--cached', '-z', '--name-only', '--diff-filter=ACMR'], + { + cwd: REPO_ROOT, + encoding: 'utf8', + } + ) + return output + .split('\0') + .map((f) => f.trim()) + .filter(Boolean) + } catch (err) { + console.error('Error fetching staged files from git:', err.message) + process.exit(1) + } +} + +function getAllRepoFiles(dir = REPO_ROOT, base = '') { + let results = [] + const entries = fs.readdirSync(dir, { withFileTypes: true }) + for (const entry of entries) { + const relPath = toPosixPath(path.join(base, entry.name)) + if (isIgnoredPath(relPath)) continue + + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + results = results.concat(getAllRepoFiles(fullPath, relPath)) + } else if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase() + if (SCANNABLE_EXTENSIONS.has(ext)) { + results.push(relPath) + } + } + } + return results +} + +function isMatchWhitelisted(line, match, whitelist) { + if (!whitelist || whitelist.length === 0) return false + const matchedText = match[0] + const matchStart = match.index + const matchEnd = matchStart + matchedText.length + + for (const wl of whitelist) { + if (wl.test(matchedText)) { + return true + } + const globalFlags = wl.flags.includes('g') ? wl.flags : `${wl.flags}g` + const globalWl = new RegExp(wl.source, globalFlags) + for (const wlMatch of line.matchAll(globalWl)) { + const wlStart = wlMatch.index + const wlEnd = wlStart + wlMatch[0].length + if (matchStart >= wlStart && matchEnd <= wlEnd) { + return true + } + } + } + return false +} + +function readTargetFileContent(relPath, isStagedOnly) { + const posixRelPath = toPosixPath(relPath) + if (isStagedOnly) { + try { + return execFileSync('git', ['show', `:${posixRelPath}`], { + cwd: REPO_ROOT, + encoding: 'utf8', + }) + } catch { + return null + } + } + + const fullPath = path.resolve(REPO_ROOT, relPath) + if (!fs.existsSync(fullPath)) return null + return fs.readFileSync(fullPath, 'utf8') +} + +function scanFile(relPath, { isStagedOnly = false } = {}) { + if (isIgnoredPath(relPath)) return [] + const content = readTargetFileContent(relPath, isStagedOnly) + if (!content) return [] + + const lines = content.split('\n') + const violations = [] + + lines.forEach((line, index) => { + // Support inline ignore: // pii-ignore or + if (line.includes('pii-ignore')) return + + for (const rule of PROHIBITED_RULES) { + const flags = rule.pattern.flags.includes('g') + ? rule.pattern.flags + : `${rule.pattern.flags}g` + const globalPattern = new RegExp(rule.pattern.source, flags) + + for (const match of line.matchAll(globalPattern)) { + if (isMatchWhitelisted(line, match, rule.whitelist)) { + continue + } + + violations.push({ + file: toPosixPath(relPath), + line: index + 1, + rule: rule.name, + match: match[0], + description: rule.description, + }) + } + } + }) + + return violations +} + +function main() { + const isStagedOnly = process.argv.includes('--staged') + const filesToScan = isStagedOnly + ? getStagedFiles().filter((f) => { + const ext = path.extname(f).toLowerCase() + return SCANNABLE_EXTENSIONS.has(ext) && !isIgnoredPath(f) + }) + : getAllRepoFiles() + + if (filesToScan.length === 0) { + console.log('✅ No scannable files found. PII check passed.') + process.exit(0) + } + + let totalViolations = [] + for (const file of filesToScan) { + const violations = scanFile(file, { isStagedOnly }) + if (violations.length > 0) { + totalViolations = totalViolations.concat(violations) + } + } + + if (totalViolations.length > 0) { + console.error( + '\n❌ PII CHECK FAILED — Prohibited Personal Information Detected:' + ) + console.error( + '─────────────────────────────────────────────────────────────────' + ) + for (const v of totalViolations) { + console.error( + `• [${v.rule}] ${v.file}:${v.line}\n Matched: "${v.match}"\n Remedy: ${v.description}\n` + ) + } + console.error( + '─────────────────────────────────────────────────────────────────' + ) + console.error( + 'The petry-projects/google-app-scripts repository is a generic open-source' + ) + console.error( + 'harness. Personal student names, private businesses, and household addresses' + ) + console.error( + 'must be injected via ScriptProperties (CUSTOM_PROMPT_RULES), never committed.' + ) + console.error( + '─────────────────────────────────────────────────────────────────\n' + ) + process.exit(1) + } + + console.log( + `✅ Strict No-PII check passed (${filesToScan.length} file(s) scanned).` + ) + process.exit(0) +} + +main() diff --git a/src/gmail-ai-classifier/.clasp.json b/src/gmail-ai-classifier/.clasp.json new file mode 100644 index 00000000..a3f99d5c --- /dev/null +++ b/src/gmail-ai-classifier/.clasp.json @@ -0,0 +1,4 @@ +{ + "scriptId": "1AzgdgLlvweAd9bZmywJ8ZfTH_4eJ3gQFw6ODW9kXcACi3jv7P9po_zOD", + "rootDir": "." +} diff --git a/src/gmail-ai-classifier/README.md b/src/gmail-ai-classifier/README.md index eb6e29ec..2ed08ebc 100644 --- a/src/gmail-ai-classifier/README.md +++ b/src/gmail-ai-classifier/README.md @@ -1,32 +1,83 @@ -# Gmail AI Classifier & Auto-Filter Engine +# Gemini AI-Powered Semantic Email Classifier & Auto-Filter Engine -A native Google Apps Script module that uses the Gemini REST API (`gemini-1.5-flash` / `gemini-2.0-flash`) to semantically classify incoming Gmail messages into standard canonical domain labels, save full-fidelity Email Thread PDFs to Google Drive, and commit Markdown summaries directly to `self-private` on GitHub via REST API. +A production-ready Google Apps Script (GAS V8) engine that automatically classifies Gmail inbox messages into 7 canonical household domain folders using Google AI Studio (Gemini 3.5 & Gemma 4 31B), creates permanent native Gmail filters for high-confidence senders, and syncs executive summaries to GitHub note repositories. --- -## Multi-Account Household Deployment Guide (Option A) +## 🌟 Multi-Account Household Setup Guide -To run this classifier across multiple household Gmail accounts (`partner@example.com` and `user@example.com`): +To run this classifier across multiple household Gmail accounts (`user1@example.com` and `user2@example.com`): -### 1. Share Google Drive Taxonomy Folders +### Step 1: Share Canonical Drive Note Folders -Share the 7 Canonical Domain folders in Google Drive (`01_Household` through `07_Community_NonProfit`) with full Editor access to `user@example.com`. +Share the 7 Canonical Domain folders in Google Drive (`01_Household` through `07_Community_NonProfit`) with full Editor access to `user2@example.com`. -### 2. Deploy Apps Script Worker Project +### Step 2: Deploy Script Instance to Account #2 -1. Log into Google Apps Script under `user@example.com`. -2. Push or import this script repository (`google-app-scripts`). -3. Set **Script Properties** under Project Settings: - - `GEMINI_API_KEY`: Your Gemini API key. - - `GITHUB_PAT`: Personal Access Token with repo access to `user-org/self-private`. -4. Set Time-Driven Trigger: Run `processEmailsWithAiClassifier` every 15 minutes. +1. Log into Google Apps Script under `user2@example.com`. +2. Push or copy `code.gs`, `config.gs`, `gitHubSync.gs`, and `appsscript.json`. +3. Open **Project Settings (gear icon)** $\rightarrow$ **Script Properties**. +4. Add the following keys: + - `GEMINI_API_KEY`: Your Google AI Studio API key. + - `GITHUB_PAT`: Fine-grained GitHub Personal Access Token with write access to your target repository. + - `GITHUB_REPO_OWNER`: Target GitHub repository owner / organization name. + - `GITHUB_REPO_NAME`: Target GitHub repository name. + - `DRIVE_ROOT_FOLDER_NAME`: _(Optional)_ Parent folder name in Google Drive under which the 7 canonical domain folders live (defaults to Drive root when omitted). + - `USER_ACCOUNT_EMAIL`: `user2@example.com` + - `CUSTOM_PROMPT_RULES`: _(Optional)_ User-specific domain and student attribution rules appended dynamically to the AI prompt without code modifications. + +### Step 3: Enable Automated Triggers + +Run `setupFiveMinuteTrigger()` in Apps Script under Account #2. + +--- + +## 🔑 Features + +- **Multi-Account Attribution**: Automatically tags entries with `- **Account**: user2@example.com` or `user1@example.com`. +- **Dynamic User Discovery**: `Session.getEffectiveUser().getEmail()` automatically populates the account email dynamically at runtime. +- **7 Canonical Domain Folders**: + - `01_Household`: Remodeling, property maintenance, contractor bids, utility bills. + - `02_Finance_Legal`: Credit card statements, tax documents, mortgage notes, purchase receipts. + - `03_Vehicles`: Vehicle registrations, parts orders, maintenance records. + - `04_Family_Health`: Personal medical notes, school portals, doctor visits, student planning. + - `05_Tech_Infrastructure`: Google Cloud spend alerts, security warnings, backup status. + - `06_Work_Career`: Professional notes, work expenses, architecture docs. + - `07_Community_NonProfit`: Sensor telemetry, non-profit BOD notes, charity updates. +- **Automated Google Drive Taxonomy Attachment Persistence**: Automatically extracts and persists attached documents (`.pdf`, `.docx`, `.xlsx`, `.csv`, etc.) along the label's 2-level Google Drive taxonomy path (`[Domain]/[Subfolder]`, or `[DRIVE_ROOT_FOLDER_NAME]/[Domain]/[Subfolder]` when `DRIVE_ROOT_FOLDER_NAME` is configured), with MD5 content fingerprint deduplication and automatic signature/tracking pixel filtering (`< 35KB` for images). Strictly ignores attachments for non-canonical emails (promotions, newsletters, spam) to prevent storage bloat. Formatted Google Drive links are automatically appended to GitHub markdown notes under `- **Attachments**:`. +- **2-Year Category Retention Engine**: Automatically purges promotional, social, and forum emails older than 2 years every Sunday at 1:00 AM, while keeping core threads **indefinitely**. The retention guard exempts any thread carrying a canonical-domain label **or one of its classifier sub-labels** (e.g. `Finance/Banking`), plus starred and user-sent/replied threads — so a core message tagged only with its sub-label is never trashed. --- -## Features +## 🛠️ Tuning & Customization Architectural Guidance + +To keep this codebase a **clean, generic, reusable open-source harness** suitable for any household or organization: + +### 1. Decouple Core Engine from Personal Specializations + +- **Generic Engine**: `code.gs` and `gitHubSync.gs` handle the orchestration: batch fetching, AI prompt assembly, JSON extraction, Gmail category tab assignment (`setGmailCategoryTab`), label lifecycle (`ensureUserLabel`), and GitHub API note ingestion. +- **No Hardcoded Personal Entities in Core**: Engine source files should remain agnostic of specific student names, local schools, private businesses, or personal contacts. +- **Extensible Prompt Injection**: When personalizing the Gemini classification rules, inject customized domain rules via `config.gs` or `ScriptProperties` rather than baking private household specifics into the open-source repository. + +### 2. Policy on One-Time Cleanups & Realignment Routines + +- **Never Embed in the Recurring Trigger**: Maintenance routines designed to fix historical label anomalies (e.g., swapping a student label across past threads) must **never** be committed into the continuous 5-minute automation loop (`processEmailsWithAiClassifier`). +- **Standalone Execution Pattern**: + 1. Implement one-off cleanups as standalone migration scripts (e.g. in a private workspace `scripts/` directory or an isolated manual function in GAS). + 2. Execute and verify completion once. + 3. Remove the migration code immediately. + +### 3. Classification Tuning Lifecycle + +- **Observation**: Periodically audit classification results using automated scanner scripts or by inspecting ingestion commit logs. +- **Generic Rule Refinement**: When common patterns emerge (e.g., delivered vs. in-transit shipments, auto-paid vs. manual bills), update the generic open-source rules so all users benefit. +- **Entity Attribution Tuning**: When student, school, or organization attribution shifts, update the user configuration mapping. +- **Verification**: Run unit tests (`npm test -- src/gmail-ai-classifier`) to verify that prompt and classification changes maintain zero regressions. + +### 4. Continuous Audit & Anomaly Detection + +To catch classification drift, orphan threads, and label collisions autonomously: -- **Multi-Account Attribution**: Automatically tags entries with `- **Account**: user@example.com` or `partner@example.com`. -- **Semantic Email Classification**: Evaluates incoming senders and body snippets into canonical domains. -- **Single Global `Processed` Marker**: Applies a single global `Processed` label to indicate Drive ingestion completion. -- **Inbox Preservation**: Processing emails into Google Drive preserves message visibility in the INBOX for human review. -- **100% Cloud-Native**: Runs natively inside Google Apps Script using `UrlFetchApp.fetch()`. +- **`setupDailyAuditTrigger()`**: Sets up a daily 6:00 AM time-driven trigger that runs `auditEmailClassifications()`. (Automatically enabled when invoking `setupFiveMinuteTrigger()`). +- **`auditEmailClassifications()`**: Inspects threads processed in the last 2 days for missing domain labels, conflicting multiple domains, or suspicious category mismatches (e.g. unfiled receipts or misrouted promotional emails). +- **Audit Email Digest**: Optionally configure the `AUDIT_DIGEST_EMAIL` ScriptProperty to receive an automated notification whenever anomalies are detected, prompting targeted tuning via `CUSTOM_PROMPT_RULES`. diff --git a/src/gmail-ai-classifier/TAXONOMY_PLAN.md b/src/gmail-ai-classifier/TAXONOMY_PLAN.md new file mode 100644 index 00000000..3e4a6d5a --- /dev/null +++ b/src/gmail-ai-classifier/TAXONOMY_PLAN.md @@ -0,0 +1,89 @@ +# Generic Taxonomy & Multi-Channel Intelligence Blueprint (Open-Source Version) + +## 1. Overview + +This document defines the generic taxonomy alignment, tag taxonomy, and hybrid compound tag decomposition architecture for the **Gemini AI-Powered Semantic Email Classifier & Google Drive Ingestion Engine**. + +--- + +## 2. Generic 7-Canonical Domain Architecture + +All email classifications, Google Drive folders, and Markdown note indices follow a standard 7-Canonical Domain schema: + +| Domain Key | Generic Folder / Index Path | Scope & Description | +| :--------------------------- | :-------------------------------- | :------------------------------------------------------------------------------- | +| **`01_Household`** | `01_Household/index.md` | Primary residence, property maintenance, home automation, utility receipts. | +| **`02_Finance_Legal`** | `02_Finance_Legal/index.md` | Bank statements, credit cards, mortgage notes, tax documents, purchase receipts. | +| **`03_Vehicles`** | `03_Vehicles/index.md` | Vehicle registrations, auto insurance, parts orders, service records. | +| **`04_Family_Health`** | `04_Family_Health/index.md` | Personal medical records, doctor visits, school portals, student planning. | +| **`05_Tech_Infrastructure`** | `05_Tech_Infrastructure/index.md` | Cloud spend alerts, security warnings, home server health, digital backups. | +| **`06_Work_Career`** | `06_Work_Career/index.md` | Work notes, architecture blueprints, professional expenses. | +| **`07_Community_NonProfit`** | `07_Community_NonProfit/index.md` | Non-profit board records, telemetry projects, charity updates. | + +--- + +## 3. Generic Tag Taxonomy & Sub-Label Matrix + +To optimize cross-channel searchability across Google Drive, Gmail, and Markdown note repositories, the AI Engine applies standardized tags and sub-labels: + +| Canonical Domain | Generic Sub-Label Taxonomy | Generic Tag Taxonomy (`tags`) | Generic Search Keywords | +| :--------------------------- | :------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------ | +| **`01_Household`** | `Household/Primary-Property`
`Household/Home-Maintenance`
`Household/Home-Automation`
`Household/Utilities` | `household`, `property`, `construction`, `remodeling`, `maintenance`, `utilities`, `smart-home` | Home remodeling, contractor bids, electric/gas receipts, smart home security alerts. | +| **`02_Finance_Legal`** | `Finance/Banking`
`Finance/Taxes`
`Finance/Purchases`
`Finance/Legal`
`Finance/Insurance` | `finance`, `banking`, `taxes`, `credit-card`, `mortgage`, `statement`, `receipt`, `invoice`, `legal` | Credit card statements, tax W2/1099s, mortgage statements, store receipts, legal contracts. | +| **`03_Vehicles`** | `Vehicles/Maintenance`
`Vehicles/Parts-Orders`
`Vehicles/Insurance`
`Vehicles/Registration` | `vehicles`, `auto`, `maintenance`, `parts`, `order`, `registration`, `insurance`, `car-care` | Vehicle service records, auto parts orders, tire receipts, license/tag renewals. | +| **`04_Family_Health`** | `Family/School-Student`
`Family/Medical-Student`
`Family/Health-General` | `family`, `health`, `medical`, `school`, `student`, `doctor` | Medical records, doctor visits, school portal notifications, student planning. | +| **`05_Tech_Infrastructure`** | `Tech/Alerts-Monitoring`
`Tech/Backups`
`Tech/Hardware`
`Tech/Cloud-GCP` | `tech`, `infrastructure`, `cloud`, `gcp`, `security`, `alerts`, `server`, `backups` | Cloud spend/security alerts, Home Assistant API logs, server health, digital backups. | +| **`06_Work_Career`** | `Work/Notes`
`Work/Expenses`
`Work/Architecture` | `work`, `career`, `notes`, `expenses`, `architecture`, `projects` | Professional work notes, architecture blueprints, work expenses. | +| **`07_Community_NonProfit`** | `Projects/Telemetry`
`Community/NonProfit-BOD`
`Community/Charity` | `community`, `nonprofit`, `projects`, `telemetry`, `charity` | Non-profit board records, telemetry sensors, charity updates. | + +--- + +## 4. Mandatory Rule: Hybrid Compound Tag Decomposition + +To guarantee both **high search precision** (zero false positives) and **high search recall** (never missing a search), the AI Engine enforces the **Hybrid Compound Tag Decomposition Rule**: + +> **Rule**: Whenever any compound (hyphenated) tag is generated (e.g. `john-doe`, `main-street`, `credit-card`, `cloud-server`), the AI Engine automatically decomposes the compound and includes each individual component word (`john`, `doe`, `main`, `street`, `credit`, `card`, `cloud`, `server`) in the document's tag array. + +### Examples of Generic Hybrid Tag Decomposition: + +| Document Context | Generated Compound Tags | Component Word Tokens | Final Hybrid Tag Array | +| :-------------------------------- | :------------------------------- | :--------------------------------- | :-------------------------------------------------------------------------- | +| **Student School / Medical Form** | `john-doe`, `sample-school` | `john`, `doe`, `sample`, `school` | `[john-doe, sample-school, john, doe, sample, school, medical]` | +| **Property Remodeling Bid** | `123-main-street`, `main-street` | `123`, `main`, `street` | `[123-main-street, main-street, 123, main, street, remodeling, contractor]` | +| **Credit Card Statement** | `credit-card`, `sample-bank` | `credit`, `card`, `sample`, `bank` | `[credit-card, credit, card, sample-bank, sample, bank, statement]` | +| **Cloud Server Security Alert** | `cloud-server`, `sample-cloud` | `cloud`, `server`, `sample` | `[cloud-server, cloud, server, security, alerts, tech]` | + +--- + +## 5. Dual-Layer Metadata & Hybrid Tagging Mechanism + +Google Drive files (Google Docs, PDFs, Word Docs, images) are tagged using **Dual-Layer Metadata Tagging** containing generic hybrid decomposed tags: + +### 1. Drive Native API Properties (Key-Value Metadata) + +```json +{ + "properties": { + "people": "John Doe, Jane Doe", + "organization": "Sample Academy, Sample Clinic", + "tags": "john-doe, sample-academy, john, doe, sample, academy, school, medical", + "domain": "04_Family_Health", + "sublabel": "Family/School-Student", + "indexed": "true" + } +} +``` + +### 2. Embedded Document Front-Matter Header + +```yaml +--- +domain: 04_Family_Health +sublabel: Family/School-Student +people: [John Doe, Jane Doe] +organization: [Sample Academy, Sample Clinic] +location: [Sample City, State] +tags: [john-doe, sample-academy, john, doe, sample, academy, school, medical] +created: 2026-07-31 +--- +``` diff --git a/src/gmail-ai-classifier/code.gs b/src/gmail-ai-classifier/code.gs index ff98a2f2..23301056 100644 --- a/src/gmail-ai-classifier/code.gs +++ b/src/gmail-ai-classifier/code.gs @@ -1,228 +1,431 @@ -/* istanbul ignore file */ -/* sonar.javascript.skipCoverage */ /** - * Main entry point for Gemini AI-Powered Semantic Email Classification and Auto-Filter Engine. - * Runs natively inside Google Apps Script (V8 runtime). - * NOSONAR — GAS runtime entry point, not testable in Node.js environment + * Gmail AI Classifier & Filing Agent + * + * Uses Gemini API to classify incoming emails into taxonomy labels, + * saves canonical attachments to Google Drive, and applies retention policies. */ -function processEmailsWithAiClassifier() { - console.log( - '[processEmailsWithAiClassifier] Starting AI semantic email processing...' + +function getFileHash(blob) { + if (!blob) return '' + try { + let bytes = blob + if (typeof blob === 'object' && typeof blob.getBytes === 'function') { + bytes = blob.getBytes() + } + if (!bytes) return '' + const util = + typeof Utilities !== 'undefined' && Utilities + ? Utilities + : typeof globalThis !== 'undefined' && globalThis.Utilities + ? globalThis.Utilities + : null + if (util && typeof util.computeDigest === 'function') { + const rawDigest = util.computeDigest(util.DigestAlgorithm.MD5, bytes) + return rawDigest + .map((b) => { + const v = b < 0 ? b + 256 : b + return v.toString(16).padStart(2, '0') + }) + .join('') + } + return '' + } catch { + return '' + } +} + +function validateClassification(classification, canonicalDomains) { + if (!classification || typeof classification !== 'object') return false + if ( + !classification.canonical_label || + typeof classification.canonical_label !== 'string' + ) + return false + if (!Number.isFinite(classification.confidence)) return false + if (classification.confidence < 0 || classification.confidence > 1) + return false + if ( + classification.reasoning === undefined || + typeof classification.reasoning !== 'string' ) - var config = getAiClassifierConfig() + return false + if ( + canonicalDomains && + !canonicalDomains.includes(classification.canonical_label) + ) + return false + return true +} - if (!config.geminiApiKey) { - console.error( - '[processEmailsWithAiClassifier] GEMINI_API_KEY ScriptProperty is missing.' - ) - return +const VALID_ONTOLOGICAL_CATEGORIES_ = [ + 'Primary', + 'Updates', + 'Promotions', + 'Social', + 'Forums', +] +const VALID_ONTOLOGICAL_ACTIONS_ = ['keep', 'archive', 'trash', 'mark_read'] + +/** + * Validates an ontological classification response object returned by classifyWithGemini. + * + * @param {Object} classification - Parsed response from Gemini + * @param {string[]} [canonicalDomains] - Allowed canonical domain keys + * @returns {boolean} + */ +function validateOntologicalClassification(classification, canonicalDomains) { + if ( + !classification || + typeof classification !== 'object' || + Array.isArray(classification) + ) { + return false + } + if ( + typeof classification.confidence === 'number' && + (!Number.isFinite(classification.confidence) || + classification.confidence < 0 || + classification.confidence > 1) + ) { + return false + } + if ( + classification.confidence !== undefined && + typeof classification.confidence !== 'number' + ) { + return false + } + var domain = classification.canonicalDomain + if (domain !== null && domain !== undefined && domain !== '') { + if (typeof domain !== 'string') return false + if ( + Array.isArray(canonicalDomains) && + canonicalDomains.length > 0 && + canonicalDomains.indexOf(domain) === -1 + ) { + return false + } + } + if ( + classification.category !== undefined && + classification.category !== null && + VALID_ONTOLOGICAL_CATEGORIES_.indexOf(classification.category) === -1 + ) { + return false + } + if ( + classification.action !== undefined && + classification.action !== null && + VALID_ONTOLOGICAL_ACTIONS_.indexOf(classification.action) === -1 + ) { + return false } + return true +} - // Debug helper: List available models for this API key - listAvailableGeminiModels(config) +function _sleep(ms) { + if (typeof Utilities !== 'undefined') Utilities.sleep(ms) +} - var threads = GmailApp.search(config.unprocessedQuery, 0, 10) - console.log( - '[processEmailsWithAiClassifier] Found ' + - threads.length + - ' unprocessed thread(s).' - ) +/** + * Constructs an Ontological Knowledge Graph & Triage Matrix prompt for Gemini classification. + * Uses positive invariant domain scopes, an orthogonal lifecycle triage matrix, + * and user entity graph injection, eliminating negative exclusion rules. + * + * @param {Object} config - Classifier configuration (canonicalDomains, customPromptRules) + * @param {string} sender - Email sender header + * @param {string} subject - Email subject line + * @param {string} snippet - Email body snippet + * @returns {string} Prompt text for Gemini API + */ +function buildOntologicalPrompt(config, sender, subject, snippet) { + const domains = (config && config.canonicalDomains) || [] + let prompt = + 'You are an executive email classifier. Perform semantic classification into ONE canonical domain key from: ' + + JSON.stringify(domains) + + ' (or null if non-canonical).\n\n' + + '=== TIER 1: DOMAIN TAXONOMY ONTOLOGY (POSITIVE INVARIANTS) ===\n' + + 'Classify emails based on what each domain positively governs:\n' + + "• '01_Household': Physical residence, real estate property, maintenance, home repairs, contractor invoices, home utilities, household inventory, and travel/lodging reservations. Valid sub-labels: 'Household/Property', 'Household/Maintenance', 'Household/Travel'.\n" + + "• '02_Finance_Legal': Personal banking, checking/savings, credit cards, investments, mortgages, personal tax filings (W-2, 1098, 1099, returns), utility payment accounts/funding, purchase invoices/receipts, health/auto/property insurance policies and explanations of benefits (EOB), actual personal legal proceedings, court orders, attorney correspondence, dispute filings, and executed personal contracts (leases, deeds, wills, trusts, powers of attorney). (Excludes commercial terms of service updates, which are non-canonical broadcast notices). Valid sub-labels: 'Finance/Banking', 'Finance/Bills', 'Finance/Purchases', 'Finance/Insurance', 'Finance/Taxes', 'Finance/Donations', 'Finance/Charitable-Donations', 'Finance/Legal'.\n" + + "• '03_Vehicles': Personal automobile titles, registrations, vehicle insurance, automotive maintenance, repairs, parts, and car rental reservations. Valid sub-labels: 'Vehicles/Maintenance', 'Vehicles/Purchases', 'Vehicles/Rental-Cars'.\n" + + "• '04_Family_Health': Family correspondence, healthcare records, doctor appointments, patient portals, prescriptions, elder care, and student education/coursework/school portals. Valid sub-labels: 'Family/Kids/Tide', 'Family/Kids/Toby', 'Family/Kids/David', 'Family/School-Student', 'Family/Medical', 'Family/Legal', 'Family/Correspondence', 'Family/Parents', 'Family/Sisters'.\n" + + "• '05_Tech_Infrastructure': Cloud hosting, server infrastructure, domains/DNS, network hardware, security alerts, system telemetry, and developer platform quota/outage alerts. Valid sub-labels: 'Tech/Cloud', 'Tech/Security', 'Tech/Alerts'.\n" + + "• '06_Work_Career': Professional employment, career advancement, job applications, recruiter correspondence, interview schedules, employer benefits, and consulting. Valid sub-labels: 'Work/Career', 'Work/Employer'.\n" + + "• '07_Community_NonProfit': Official 501(c)(3) charities, non-profit boards of directors, volunteer shift schedules, civic records, community/apiary telemetry, and non-profit initiatives. Valid sub-labels: 'Projects/Charity', 'Community/BOD', 'Projects/Telemetry', 'Projects/Beekeeping', 'Projects/HOG'.\n\n" + + 'NON-CANONICAL EMAILS (canonicalDomain: null):\n' + + '• Media, platform, and enterprise daily/weekly activity digests and newsletters (Substack, Medium, LinkedIn digests, corporate/enterprise platform activity digests, daily digest emails, news recaps, blogs, trade publications) -> canonicalDomain: null, subLabel: null, category: "Updates" (or "Promotions"), action: "archive".\n' + + '• Retail marketing, store discounts, commercial coupons, equipment supply store sales, and e-commerce promotional blasts (e.g. retail equipment/tools/bee supply discounts, store newsletters, commercial promotional sales) -> canonicalDomain: null, subLabel: null, category: "Promotions", action: "archive". (Distinguish commercial store sales from actual community/apiary telemetry or non-profit initiatives).\n' + + '• Customer satisfaction surveys, Net Promoter Score (NPS) ratings, support feedback forms, and service questionnaires (e.g. airline/travel surveys, cloud/developer platform feedback surveys, camp/program feedback ratings, vendor review requests) -> canonicalDomain: null, subLabel: null, category: "Promotions" (or "Updates"), action: "archive".\n' + + '• Political campaign communications, candidate fundraising appeals, PAC broadcasts, election solicitations, and news subscription pitches (e.g. campaign fundraising appeals, news subscription drives, daily news digests) -> canonicalDomain: null, subLabel: null, category: "Promotions" (or "Updates"), action: "archive".\n' + + '• Commercial webinars, product demos, vendor marketing broadcasts.\n' + + '• Unsolicited real estate cold calls, off-market wholesaler pitches, bulk solicitation.\n' + + '• Commercial Terms of Service (TOS) updates, privacy policy revisions, arbitration updates, and platform terms/agreements (e.g. Waymo, Google, Uber, Apple, bank policy updates) -> non-canonical broadcast notices; route to category "Promotions" (or "Updates") with action "archive".\n\n' + + 'AUTOMATED SEARCH & MONITORING ALERTS (Google Alerts, Talkwalker, CourtListener, web mentions):\n' + + 'Route monitoring alerts strictly according to the subject entity being monitored:\n' + + "• Person, family member, elder care, or genealogy monitoring -> '04_Family_Health' ('Family/Legal' or 'Family/Correspondence') or '02_Finance_Legal' ('Finance/Legal').\n" + + "• Municipal, neighborhood, zoning, or real property monitoring -> '01_Household' ('Household/Property').\n" + + "• Corporate, business, industry, or career monitoring -> '06_Work_Career' ('Work/Career').\n" + + "• General news or unassigned media mention -> canonicalDomain: null (category: 'Updates', action: 'archive').\n" + + '• Student or school sub-labels apply only when the monitored alert query specifically targets an academic program or school.\n\n' + + 'AUTOMATED MACHINE & SENSOR TELEMETRY (BroodMinder, weather stations, IoT sensors, server metrics, device status pings, cron logs, uptime monitors):\n' + + '• Community/beehive telemetry (BroodMinder monitors, "Events in your apiaries", hive alerts) -> "07_Community_NonProfit", sub-label "Projects/Telemetry" or "Projects/Beekeeping".\n' + + '• Tech infrastructure/server telemetry (device pings, server metrics, cron logs) -> "05_Tech_Infrastructure", sub-label "Tech/Alerts".\n' + + '• MANDATORY ROUTING: Always route machine telemetry to category "Updates" with action "archive". Keep telemetry completely archived out of the Inbox.\n\n' + + 'SCHOOL & STUDENT ANNOUNCEMENTS VS DIRECT CORRESPONDENCE (MCAA, Briarwood, FACTS, PowerSchool, ParentSquare, Canvas, Google Classroom, Colleges):\n' + + '• Routine school announcements, district/ParentSquare broadcasts, headmaster/principal letters, athletics/arts/club notices (e.g. "Noteworthy News", "Athletics Newsletter", "Lion Guard Update"), lunch menus, school calendars, college/admissions parent newsletters (e.g. "Parent and Family Newsletter"), Google Classroom summaries, FACTS bulletins, PowerSchool broadcasts, and weekly school newsletters -> "04_Family_Health", sub-label "Family/School-Student", category: "Updates", action: "archive". Ingest and log them to notes, but keep them archived out of the Inbox (Updates tab).\n' + + '• Direct 1:1 personal emails from an individual teacher, principal, or guidance counselor addressed personally to the parent regarding an individual student\'s urgent academic/disciplinary matter or conference request -> category: "Primary", action: "keep".\n\n' + + 'NON-PROFIT & CHARITY BROADCASTS VS DIRECT CORRESPONDENCE:\n' + + '• Routine mass newsletters, monthly impact reports, event notices, general fundraising appeals, and volunteer list blasts from non-profits or charities (e.g. Community Food Bank, The Lovelady Center, Southeastern Climbers Coalition, Grace Klein Community mass mail, Restoration Academy bulletins) -> "07_Community_NonProfit", sub-label "Projects/Charity", category: "Updates", action: "archive". Ingest and log them to notes, but keep them archived out of the Inbox.\n' + + '• Direct 1:1 personal emails from non-profit leadership, board members, or staff addressed specifically to the individual (or personal volunteer shift confirmations needing attendance) -> "07_Community_NonProfit", category: "Primary", action: "keep".\n\n' + + 'TECH PLATFORM NOTIFICATIONS & SYSTEM ANNOUNCEMENTS:\n' + + '• Automated developer platform notifications, deprecation alerts, quota notices, CI/CD run failure notices (e.g. Google AI Studio, Linear, GitHub, Cloud monitoring) -> "05_Tech_Infrastructure", sub-label "Tech/Alerts", category: "Updates", action: "archive". Ingest and log them to notes, but keep them archived out of the Inbox.\n' + + '• Critical security incidents or billing payment failure notices requiring manual human intervention -> "05_Tech_Infrastructure", sub-label "Tech/Security" or "Tech/Alerts", category: "Primary", action: "keep".\n\n' + + 'PRIMARY PARTY ATTRIBUTION PRINCIPLE (CHILDREN & PRIMARY BENEFICIARY VS INCIDENTAL ADULTS):\n' + + '• When an email, legal document, school notification, or healthcare record pertains to a specific child/individual (e.g. Tide, Toby, David, or any household child), classify the email under that primary party\'s specific label (e.g. "Family/Kids/Tide", "Family/Kids/Toby", "Family/Kids/David", or "Family/School-Student").\n' + + '• Attribute the email strictly to the PRIMARY SUBJECT / TARGET BENEFICIARY of the matter, rather than incidental parties:\n' + + ' - If an email concerns Tide\'s legal name change, government IDs, FSA ID, schooling, or medical care, the primary party is Tide -> "Family/Kids/Tide" (or "02_Finance_Legal" / "Family/Legal" if a formal court filing).\n' + + ' - Route child-related legal, educational, or personal matters to the child\'s sub-label, ensuring that mentioned biological parents, guardians, or relatives do not divert the classification to "Family/Sisters".\n' + + ' - Sub-label "Family/Sisters" applies strictly to direct personal correspondence regarding the sister\'s own independent adult personal affairs.\n\n' + + '=== TIER 2: ORTHOGONAL TRIAGE MATRIX (LIFECYCLE STATE) ===\n' + + 'Determine category and action based on the lifecycle state of the email:\n' + + '1. Action_Required (Manual bills due without auto-pay, direct 1:1 personal/teacher/counselor correspondence needing individual human reply, audit/response deadlines, suspicious login alerts, ready prescriptions):\n' + + " -> category: 'Primary', action: 'keep'\n" + + '2. Informational_Feed (Routine school bulletins/newsletters/ParentSquare updates, non-profit newsletters/appeals, automated search/monitoring alerts, active orders in transit, routine tax forms/receipts, volunteer reminders, upcoming travel itineraries):\n' + + " -> category: 'Updates', action: 'archive'\n" + + '3. Completed_Transaction (Confirmed scheduled auto-payments, delivered packages, successful SSO/sign-ins, routine lunch menu digests, bank transfers, automated machine/sensor telemetry [BroodMinder, apiary events, HoneyBeeham], device/server pings):\n' + + " -> category: 'Updates', action: 'archive'\n" + + '4. Broadcast_Marketing (Commercial promos, retail discounts, vendor newsletters, terms of service and privacy policy updates):\n' + + " -> category: 'Promotions', action: 'archive'\n" + + '5. Spam_Solicitation (Phishing, scam attempts, cold wholesaler pitches):\n' + + " -> category: 'Promotions', action: 'trash'\n\n" + + 'CATEGORY-DRIVEN INBOX RETENTION INVARIANT & PRIMARY INBOX PURITY RULE (ACTION-FIRST INBOX):\n' + + '• Primary Inbox is an Action Queue: Only emails categorized as "Primary" belong in the Primary Inbox (action: "keep").\n' + + '• Emails that do not require prompt, personalized human action (such as automated newsletters, order receipts, shipping updates, travel confirmations, account alerts, credit scores, smart reorders, school bulletins, marketing promos, and social pings) MUST NOT stay in Primary (they must be categorized as "Updates", "Promotions", or "Social" with action: "archive"). Reserve category: "Primary" and action: "keep" ONLY for authentic human-to-human correspondence, critical urgent action items, or manual bills needing immediate human review.\n' + + '• All non-Primary streams ("Updates", "Promotions", "Social", "Forums") are Informational Feeds that must be archived out of the Inbox (action: "archive") into their respective category tab and domain sub-label.\n\n' + + '=== CONSTRAINTS ===\n' + + '• Single Sub-Label Invariant: Return AT MOST ONE sub-label string (e.g. "Finance/Banking" or "Family/School-Student"). Do not stack or combine multiple sub-labels.\n\n' - if (threads.length === 0) { - return + if (config && config.customPromptRules) { + prompt += + '=== TIER 3: USER ENTITY KNOWLEDGE GRAPH & CUSTOM RULES ===\n' + + config.customPromptRules + + '\n\n' } - var processedLabel = ensureUserLabel(config.processedLabel) + prompt += + 'Sender: ' + + sender + + '\n' + + 'Subject: ' + + subject + + '\n' + + 'Body Snippet: ' + + snippet + + '\n\n' + + 'Return JSON ONLY: {"canonicalDomain": "01_Household", "subLabel": "Household/Property", "category": "Updates", "action": "archive", "confidence": 0.98, "title": "Short Title", "summary": "2 sentence executive summary"}\n' + + "Valid categories: 'Primary', 'Updates', 'Promotions', 'Social', 'Forums'.\n" + + "Valid actions: 'keep', 'archive', 'trash', 'mark_read'." - for (var i = 0; i < threads.length; i++) { - var thread = threads[i] - var firstMessage = thread.getMessages()[0] - var sender = firstMessage.getFrom() - var subject = firstMessage.getSubject() - var snippet = firstMessage.getPlainBody().substring(0, 500) + return prompt +} - console.log( - '[processEmailsWithAiClassifier] Processing (' + - (i + 1) + - '/' + - threads.length + - '): ' + - subject + - ' from ' + - sender - ) +/** + * Calls the Gemini REST API and returns a validated classification object, or null. + * + * Returns null on network errors, malformed responses, refused responses, or + * responses whose canonical_label does not match a configured domain. + * Retries transient 429/5xx responses with exponential backoff (up to 3 attempts). + * + * @param {Object} config - Classifier configuration (modelEndpoint, geminiApiKey, canonicalDomains) + * @param {string} sender - Email sender address + * @param {string} subject - Email subject line + * @param {string} bodyText - Plain-text body snippet (max 1,200 chars recommended) + * @param {Object} urlFetchApp - GAS UrlFetchApp service (injected for testability) + * @param {Function} [sleepFn] - Sleep function for retry delays (injected for testability, defaults to _sleep) + * @returns {Object|null} Validated classification or null + */ +function classifyEmailWithGemini( + config, + sender, + subject, + bodyText, + urlFetchApp, + sleepFn +) { + const url = config.modelEndpoint + const sleep = sleepFn || _sleep - var classification = classifyWithGemini(sender, subject, snippet, config) - if (classification) { - // 1. Domain & Sub-Label Tagging - if (classification.canonicalDomain) { - var targetLabel = ensureUserLabel(classification.canonicalDomain) - thread.addLabel(targetLabel) - console.log( - '[processEmailsWithAiClassifier] Tagged thread with domain label: ' + - classification.canonicalDomain - ) + const prompt = + 'You are an executive email classifier.\n' + + 'Classify the following email into exactly ONE of these canonical domain labels:\n' + + config.canonicalDomains.join('\n') + + '\n\n' + + 'Email Details:\n' + + 'Sender: ' + + sender + + '\n' + + 'Subject: ' + + subject + + '\n' + + 'Body Snippet: ' + + bodyText + + '\n\n' + + 'Respond ONLY in JSON format with these exact fields:\n' + + '{"canonical_label": "", "confidence": <0.0-1.0>, "reasoning": ""}' - if (classification.subLabel) { - var subLabelObj = ensureUserLabel(classification.subLabel) - thread.addLabel(subLabelObj) - console.log( - '[processEmailsWithAiClassifier] Tagged thread with sub-label: ' + - classification.subLabel - ) - } + const payload = { + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { response_mime_type: 'application/json' }, + } - // Auto-Create Filter Rule if Confidence >= 0.95 - if (classification.confidence >= config.autoFilterConfidenceThreshold) { - var ruleLabel = - classification.subLabel || classification.canonicalDomain - createGmailFilterRule(sender, ruleLabel) - } + const options = { + method: 'post', + contentType: 'application/json', + headers: { + 'x-goog-api-key': config.geminiApiKey, + }, + payload: JSON.stringify(payload), + muteHttpExceptions: true, + } - // Sync Progressive Disclosure Summary to GitHub - if (config.githubToken) { - var notePath = getNotePathForDomain(classification.canonicalDomain) - if (notePath) { - var dateStr = Utilities.formatDate( - firstMessage.getDate(), - 'GMT', - 'yyyy-MM-dd' - ) - var entryMd = formatProgressiveDisclosureEntry( - dateStr, - classification.title || subject, - sender, - subject, - classification.summary, - config.userAccountEmail - ) - appendMarkdownEntryToGitHubRepo( - notePath, - entryMd, - 'feat(ingestion): ' + subject - ) - } + const MAX_ATTEMPTS = 3 + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + try { + const response = urlFetchApp.fetch(url, options) + const statusCode = response.getResponseCode() + + if (statusCode === 429 || (statusCode >= 500 && statusCode < 600)) { + if (attempt < MAX_ATTEMPTS - 1) { + sleep(Math.pow(2, attempt) * 1000) + continue } + console.error( + '[classifyEmailWithGemini] Transient error after retries, status:', + statusCode + ) + return null } - // 2. Action Handling (Trash, Archive, Mark Read) - if (classification.action === 'trash') { - thread.moveToTrash() - console.log( - '[processEmailsWithAiClassifier] Action: Moved spam/unwanted thread to TRASH.' - ) - } else if (classification.action === 'archive') { - thread.moveToArchive() - console.log( - '[processEmailsWithAiClassifier] Action: Archived thread out of INBOX.' + if (statusCode < 200 || statusCode >= 300) { + console.error( + '[classifyEmailWithGemini] HTTP error from Gemini API, status:', + statusCode ) + return null } - if (classification.action === 'mark_read' || classification.markRead) { - thread.markRead() - console.log( - '[processEmailsWithAiClassifier] Action: Marked thread as READ.' + const jsonText = response.getContentText() + const parsed = JSON.parse(jsonText) + const outputText = parsed.candidates[0].content.parts[0].text + const classification = JSON.parse(outputText) + if (!validateClassification(classification, config.canonicalDomains)) { + console.error( + '[classifyEmailWithGemini] Invalid classification response:', + JSON.stringify(classification) ) + return null } + return classification + } catch (e) { + console.error( + '[classifyEmailWithGemini] Error calling Gemini REST API:', + e.message + ) + return null } - - // Apply Single Global Processed Label (preserves INBOX visibility unless trashed/archived) - thread.addLabel(processedLabel) - - // Sleep 2 seconds between emails to avoid hitting API rate limits - Utilities.sleep(2000) } - - console.log('[processEmailsWithAiClassifier] Batch processing complete.') -} - -/** - * Creates an automatic Cloud Trigger that runs email classification every 5 minutes 24/7. - */ -function setupFiveMinuteTrigger() { - stopAllTriggers() - ScriptApp.newTrigger('processEmailsWithAiClassifier') - .timeBased() - .everyMinutes(5) - .create() - console.log( - '[setupFiveMinuteTrigger] Successfully established 5-minute recurring cloud trigger.' - ) } -/** - * Clears all active time-driven triggers for this script. - */ -function stopAllTriggers() { - var triggers = ScriptApp.getProjectTriggers() - for (var i = 0; i < triggers.length; i++) { - ScriptApp.deleteTrigger(triggers[i]) +function computeStringHash(str, services) { + if (!str) return '' + try { + var utils = + (services && services.Utilities) || + (typeof Utilities !== 'undefined' ? Utilities : null) + if (utils && typeof utils.computeDigest === 'function') { + var algo = (utils.DigestAlgorithm && utils.DigestAlgorithm.MD5) || 'MD5' + var digest = utils.computeDigest(algo, str) + return digest + .map(function (b) { + return ('0' + (b & 0xff).toString(16)).slice(-2) + }) + .join('') + } + } catch { + // Non-blocking fallback } - console.log('[stopAllTriggers] All script triggers removed.') + var hash = 0 + for (var i = 0; i < str.length; i++) { + hash = Math.trunc((hash << 5) - hash + str.charCodeAt(i)) + } + return 'h_' + Math.abs(hash).toString(16) } -function listAvailableGeminiModels(config) { - var url = - 'https://generativelanguage.googleapis.com/v1beta/models?key=' + - config.geminiApiKey - try { - var response = UrlFetchApp.fetch(url, { muteHttpExceptions: true }) - if (response.getResponseCode() === 200) { - var data = JSON.parse(response.getContentText()) - var modelNames = (data.models || []).map(function (m) { - return m.name - }) - console.log( - '[listAvailableGeminiModels] Available models for key:', - JSON.stringify(modelNames) - ) - return modelNames - } else { - console.warn( - '[listAvailableGeminiModels] HTTP ' + - response.getResponseCode() + - ': ' + - response.getContentText() - ) +function classifyWithGemini(sender, subject, snippet, config, services) { + var urlFetch = + (services && services.UrlFetchApp) || + (typeof UrlFetchApp !== 'undefined' ? UrlFetchApp : null) + var sleepFn = + (services && services.sleepFn) || + (services && services.Utilities && services.Utilities.sleep) || + (typeof Utilities !== 'undefined' && Utilities && Utilities.sleep + ? Utilities.sleep + : function () {}) + var cache = + (services && services.CacheService) || + (typeof CacheService !== 'undefined' && + CacheService && + CacheService.getScriptCache + ? CacheService.getScriptCache() + : null) + var props = + (services && + services.PropertiesService && + typeof services.PropertiesService.getScriptProperties === 'function' + ? services.PropertiesService.getScriptProperties() + : null) || + (services && services.properties) || + (typeof PropertiesService !== 'undefined' && + typeof PropertiesService.getScriptProperties === 'function' + ? PropertiesService.getScriptProperties() + : null) + + var canonicalDomains = config && config.canonicalDomains + + // 1. Check classification cache + var cacheKey = null + if (cache) { + var rawKey = (sender || '') + '|' + (subject || '') + '|' + (snippet || '') + var hashed = computeStringHash(rawKey, services) + if (hashed) { + cacheKey = 'cls_' + hashed + try { + var cachedJson = cache.get(cacheKey) + if (cachedJson) { + var cachedResult = JSON.parse(cachedJson) + if ( + validateOntologicalClassification(cachedResult, canonicalDomains) + ) { + console.log('[classifyWithGemini] Cache hit for: ' + subject) + return cachedResult + } + } + } catch { + // Cache read is non-blocking + } } - } catch (e) { + } + + // 2. Check circuit breaker before hitting remote endpoints + if (isCircuitBreakerOpen(props)) { console.warn( - '[listAvailableGeminiModels] Error querying models:', - e.message + '[classifyWithGemini] Circuit breaker is open. Skipping remote API call.' ) + return null } - return [] -} -function classifyWithGemini(sender, subject, snippet, config) { - var prompt = - 'Classify this email into ONE of these canonical domain keys: ' + - JSON.stringify(config.canonicalDomains) + - '.\n\n' + - 'STRICT CLASSIFICATION RULES:\n' + - "1. MEDIA & PLATFORM NEWSLETTERS (Medium, NYT, Substack, Epoch Times, LinkedIn digests, event/news blasts): Treat strictly as Promotional / Newsletter and return null for canonicalDomain. Do NOT classify under '06_Work_Career' or '04_Family_Health_School'. Set category to 'Promotions' or 'Social', action to 'keep'.\n" + - "2. UTILITY & TECH BILLS (AT&T, Google Cloud, Electric, Water): Classify under '02_Finance_Legal' (sub-label 'Finance/Banking') or '05_Tech_Infrastructure' (sub-label 'Tech/Alerts-Monitoring'). Set category to 'Updates', action to 'keep'.\n" + - "3. MARRIAGE & ADULT FAMILY (WinShape, Marriage retreats, Spouse & Primary personal): Classify under '04_Family_Health_School' (sub-label 'Family/Spouse-Primary'). Set category to 'Primary', action to 'keep'.\n" + - "4. BEEKEEPING & MYBROODMINDER ALERTS (MyBroodMinder, Hive telemetry alerts, BOD): Classify under '07_Community_NonProfit' (sub-label 'Projects/Beekeeping'). Set category to 'Updates', action to 'keep'.\n" + - "5. HEALTH NEWSLETTERS & MEDICAL BULLETINS (WebMD, Epoch Health, drug recall news digests): Treat as Newsletter and return null for canonicalDomain. Reserve '04_Family_Health_School' strictly for personal family medical records, doctor visits, patient portals, and school/kids health notes.\n" + - "6. E-COMMERCE PROMOTIONS & SOCIAL DIGESTS (Lowes, Nextdoor, American Meadows, Hydrobuilder): Return null for canonicalDomain. Set category to 'Promotions' or 'Social'.\n" + - "7. SCHOOL PORTALS & PARENTSQUARE (ParentSquare, Magic City Acceptance Academy, MCAA, school shuttle notifications, school attendance): Classify under '04_Family_Health_School' (sub-label 'Family/School-Child'). Set category to 'Updates' or 'Primary', action to 'keep'.\n" + - "8. TECH WEBINARS & PRODUCT MARKETING (Google Cloud webinars, 'Register Now', product marketing, tech promos): Treat as Promotional / Marketing and return null for canonicalDomain. Reserve '05_Tech_Infrastructure' strictly for active system alerts, security warnings, spend cap notifications, and project quota/outage alerts.\n" + - "9. HOBBY & STORE MARKETING (Lorob Bees, Foxhound Bee Company, e-commerce store newsletters, product announcements): Treat as Promotional / Marketing and return null for canonicalDomain. Reserve '07_Community_NonProfit' / 'Projects/Beekeeping' strictly for active hive telemetry alerts (MyBroodMinder) and official non-profit BOD communications.\n" + - "10. SPAM & PHISHING & UNWANTED SOLICITATION: Set action to 'trash'.\n" + - "11. ROUTINE NOISY NOTIFICATIONS (Known daily digest notifications that require no reading): Set action to 'mark_read' or 'archive'.\n\n" + - 'Sender: ' + - sender + - '\n' + - 'Subject: ' + - subject + - '\n' + - 'Body Snippet: ' + - snippet + - '\n\n' + - 'Return JSON ONLY: {"canonicalDomain": "", "subLabel": "", "category": "", "action": "keep", "confidence": 0.xx, "title": "", "summary": "<summary>"}\n' + - "Valid categories: 'Primary', 'Updates', 'Promotions', 'Social', 'Forums'.\n" + - "Valid actions: 'keep', 'archive', 'trash', 'mark_read'." + var prompt = buildOntologicalPrompt(config, sender, subject, snippet) var payload = { contents: [ @@ -230,27 +433,49 @@ function classifyWithGemini(sender, subject, snippet, config) { parts: [{ text: prompt }], }, ], + generationConfig: { response_mime_type: 'application/json' }, } - // Endpoint Priority Matrix featuring High-Free-Tier Gemma 4 31B and Gemini 3.x - var endpoints = [ - 'https://generativelanguage.googleapis.com/v1beta/models/gemma-4-31b-it:generateContent', - 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash-lite:generateContent', - 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite:generateContent', - 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.0-flash:generateContent', + // Cascading Gemini Model Matrix (Production GA models preferred) + var endpoints = [] + if (config && config.modelEndpoint) { + endpoints.push(config.modelEndpoint) + } + var fallbackModels = [ + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent', + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent', + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent', + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent', + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent', + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-latest:generateContent', ] + for (var f = 0; f < fallbackModels.length; f++) { + if (endpoints.indexOf(fallbackModels[f]) === -1) { + endpoints.push(fallbackModels[f]) + } + } + + if (!urlFetch) { + console.error('[classifyWithGemini] UrlFetchApp service is not available.') + return null + } + + var maxRetryDelayMs = null for (var e = 0; e < endpoints.length; e++) { - var url = endpoints[e] + '?key=' + config.geminiApiKey + var url = endpoints[e] var options = { method: 'post', contentType: 'application/json', + headers: { + 'x-goog-api-key': (config && config.geminiApiKey) || '', + }, payload: JSON.stringify(payload), muteHttpExceptions: true, } try { - var response = UrlFetchApp.fetch(url, options) + var response = urlFetch.fetch(url, options) var statusCode = response.getResponseCode() var jsonText = response.getContentText() @@ -265,15 +490,35 @@ function classifyWithGemini(sender, subject, snippet, config) { ) { var textOutput = resData.candidates[0].content.parts[0].text var parsedObj = extractJsonSubstring(textOutput) - if (parsedObj) { + if ( + parsedObj && + validateOntologicalClassification(parsedObj, canonicalDomains) + ) { console.log( '[classifyWithGemini] Success using endpoint: ' + endpoints[e] ) + if (props) { + recordCircuitBreakerSuccess(props) + } + if (cache && cacheKey) { + try { + cache.put(cacheKey, JSON.stringify(parsedObj), 21600) + } catch { + // Cache write is non-blocking + } + } return parsedObj } + console.warn( + '[classifyWithGemini] Invalid ontological response schema from endpoint: ' + + endpoints[e] + ) } } else if (statusCode === 429) { var delayMs = parseRetryDelayMs(response) + if (maxRetryDelayMs === null || delayMs > maxRetryDelayMs) { + maxRetryDelayMs = delayMs + } console.warn( '[classifyWithGemini] Endpoint ' + endpoints[e] + @@ -281,7 +526,7 @@ function classifyWithGemini(sender, subject, snippet, config) { delayMs / 1000 + 's before fallback...' ) - Utilities.sleep(delayMs) + sleepFn(delayMs) } else { console.warn( '[classifyWithGemini] Endpoint ' + @@ -299,12 +544,24 @@ function classifyWithGemini(sender, subject, snippet, config) { ': ' + err.message ) + if ( + err.message && + err.message.indexOf('Service invoked too many times') !== -1 + ) { + console.error( + '[classifyWithGemini] UrlFetchApp daily quota exhausted. Halting remote API calls.' + ) + break + } } } console.error( '[classifyWithGemini] All model endpoints failed or rate-limited.' ) + if (props) { + recordCircuitBreakerFailure(props, maxRetryDelayMs) + } return null } @@ -321,11 +578,16 @@ function extractJsonSubstring(text) { var rawJson = text.substring(start, end + 1) try { return JSON.parse(rawJson) - } catch (e) { - console.warn( - '[extractJsonSubstring] JSON parse error on raw substring:', - e.message - ) + } catch { + try { + var cleaned = rawJson.replace(/,\s*([\]}])/g, '$1') + return JSON.parse(cleaned) + } catch (e2) { + console.warn( + '[extractJsonSubstring] JSON parse error on raw substring:', + e2.message + ) + } } } return null @@ -333,25 +595,33 @@ function extractJsonSubstring(text) { function parseRetryDelayMs(response) { try { - var headers = response.getHeaders() + var headers = + (response && typeof response.getHeaders === 'function' + ? response.getHeaders() + : null) || {} var retryHeader = headers['Retry-After'] || headers['retry-after'] if (retryHeader) { - var seconds = parseInt(retryHeader, 10) - if (!isNaN(seconds) && seconds > 0) { - return Math.min(seconds * 1000, 30000) // Cap max sleep at 30 seconds to prevent GAS execution timeout - } - } - - var jsonText = response.getContentText() - var resData = JSON.parse(jsonText) - if (resData.error && resData.error.details) { - for (var i = 0; i < resData.error.details.length; i++) { - var detail = resData.error.details[i] - if (detail.retryDelay) { - var secStr = detail.retryDelay.replace('s', '') - var sec = parseFloat(secStr) - if (!isNaN(sec) && sec > 0) { - return Math.min(Math.ceil(sec * 1000), 30000) + var seconds = Number.parseInt(retryHeader, 10) + if (!Number.isNaN(seconds) && seconds > 0) { + return Math.min(seconds * 1000, 30000) + } + } + + var jsonText = + (response && typeof response.getContentText === 'function' + ? response.getContentText() + : null) || '' + if (jsonText) { + var resData = JSON.parse(jsonText) + if (resData.error && resData.error.details) { + for (var i = 0; i < resData.error.details.length; i++) { + var detail = resData.error.details[i] + if (detail.retryDelay) { + var secStr = detail.retryDelay.replace('s', '') + var sec = Number.parseFloat(secStr) + if (!Number.isNaN(sec) && sec > 0) { + return Math.min(Math.ceil(sec * 1000), 30000) + } } } } @@ -359,85 +629,5435 @@ function parseRetryDelayMs(response) { } catch (e) { console.warn('[parseRetryDelayMs] Failed to parse retry delay:', e.message) } - return 5000 // 5-second default fallback + return 5000 +} + +/** + * Cross-execution circuit breaker state check. + * Checks whether remote AI calls should be skipped due to active backoff. + */ +function isCircuitBreakerOpen(props, nowMs) { + if (!props || typeof props.getProperty !== 'function') return false + var untilStr = props.getProperty('GEMINI_BACKOFF_UNTIL') + if (!untilStr) return false + var until = Number.parseInt(untilStr, 10) + if (Number.isNaN(until)) return false + var now = typeof nowMs === 'number' ? nowMs : Date.now() + return now < until } -function ensureUserLabel(labelName) { - var label = GmailApp.getUserLabelByName(labelName) - if (!label) { - label = GmailApp.createLabel(labelName) - console.log('[ensureUserLabel] Created new label: ' + labelName) +/** + * Records a remote API failure in PropertiesService, applying exponential backoff. + */ +function recordCircuitBreakerFailure(props, retryDelayMs, nowMs) { + if (!props || typeof props.setProperty !== 'function') return 0 + var now = typeof nowMs === 'number' ? nowMs : Date.now() + var currentFailures = 0 + if (typeof props.getProperty === 'function') { + var fStr = props.getProperty('GEMINI_BACKOFF_FAILURES') + currentFailures = Number.parseInt(fStr || '0', 10) + if (Number.isNaN(currentFailures)) currentFailures = 0 + } + var failures = currentFailures + 1 + props.setProperty('GEMINI_BACKOFF_FAILURES', String(failures)) + + var backoffMs = 60000 * Math.pow(2, failures - 1) + if (typeof retryDelayMs === 'number' && retryDelayMs > backoffMs) { + backoffMs = retryDelayMs + } + if (backoffMs > 3600000) { + backoffMs = 3600000 } - return label + var backoffUntil = now + backoffMs + props.setProperty('GEMINI_BACKOFF_UNTIL', String(backoffUntil)) + return backoffMs } -function createGmailFilterRule(senderEmail, targetLabelName) { - if (typeof Gmail === 'undefined' || !Gmail.Users || !Gmail.Users.Settings) { - console.log( - '[createGmailFilterRule] Advanced Gmail API service not enabled in script. Skipping filter creation.' - ) - return +/** + * Resets the cross-execution circuit breaker upon a successful remote API call. + */ +function recordCircuitBreakerSuccess(props) { + if (!props) return + if (typeof props.deleteProperty === 'function') { + props.deleteProperty('GEMINI_BACKOFF_UNTIL') + props.deleteProperty('GEMINI_BACKOFF_FAILURES') + } else if (typeof props.setProperty === 'function') { + props.setProperty('GEMINI_BACKOFF_UNTIL', '') + props.setProperty('GEMINI_BACKOFF_FAILURES', '0') } +} +/** + * Retrieves the retry failure count for a thread from cache. + */ +function getThreadRetryCount_(thread, cache) { + if (!thread || !cache || typeof cache.get !== 'function') return 0 + var threadId = typeof thread.getId === 'function' ? thread.getId() : '' + if (!threadId) return 0 + var val = cache.get('retry_count_' + threadId) + var count = Number.parseInt(val || '0', 10) + return Number.isNaN(count) ? 0 : count +} + +/** + * Increments the retry failure count for a thread in cache (6h TTL). + */ +function incrementThreadRetryCount_(thread, cache) { + if (!thread || !cache || typeof cache.put !== 'function') return 1 + var threadId = typeof thread.getId === 'function' ? thread.getId() : '' + if (!threadId) return 1 + var count = getThreadRetryCount_(thread, cache) + 1 try { - var targetLabel = ensureUserLabel(targetLabelName) - var filter = { - criteria: { from: senderEmail }, - action: { addLabelIds: [targetLabel.getId()] }, + cache.put('retry_count_' + threadId, String(count), 21600) + } catch { + // Cache write is non-blocking + } + return count +} + +/** + * Clears the retry failure count for a thread from cache. + */ +function clearThreadRetryCount_(thread, cache) { + if (!thread || !cache || typeof cache.remove !== 'function') return + var threadId = typeof thread.getId === 'function' ? thread.getId() : '' + if (!threadId) return + try { + cache.remove('retry_count_' + threadId) + } catch { + // Non-blocking + } +} + +function listAvailableGeminiModels(config, services) { + var urlFetch = + (services && services.UrlFetchApp) || + (typeof UrlFetchApp !== 'undefined' ? UrlFetchApp : null) + if (!urlFetch) return [] + var cfg = + config || + (typeof getAiClassifierConfig === 'function' ? getAiClassifierConfig() : {}) + if (!cfg || !cfg.geminiApiKey) return [] + var url = 'https://generativelanguage.googleapis.com/v1beta/models' + try { + var response = urlFetch.fetch(url, { + headers: { + 'x-goog-api-key': cfg.geminiApiKey, + }, + muteHttpExceptions: true, + }) + if (response.getResponseCode() === 200) { + var data = JSON.parse(response.getContentText()) + var modelNames = (data.models || []).map(function (m) { + return m.name + }) + console.log( + '[listAvailableGeminiModels] Available models for key:', + JSON.stringify(modelNames) + ) + return modelNames + } else { + console.warn( + '[listAvailableGeminiModels] HTTP ' + + response.getResponseCode() + + ': ' + + response.getContentText() + ) } - Gmail.Users.Settings.Filters.create(filter, 'me') - console.log( - '[createGmailFilterRule] Created permanent Gmail filter rule for sender: ' + - senderEmail - ) } catch (e) { console.warn( - '[createGmailFilterRule] Filter rule creation skipped/failed:', + '[listAvailableGeminiModels] Error querying models:', e.message ) } + return [] } -function getNotePathForDomain(domain) { - var map = { - '01_Household': 'household-vault/birmingham/index.md', - '02_Finance_Legal': 'household-vault/finances/index.md', - '03_Vehicles': 'household-vault/vehicles/index.md', - '04_Family_Health_School': 'household-vault/kids/index.md', - '05_Tech_Infrastructure': - 'household-vault/our-technology/digital-backups/index.md', - '06_Work_Career': 'dp-work-notes/notes/index.md', - '07_Community_NonProfit': - 'helpingoneguy/organization/organization/index.md', +/** + * Ensures a Gmail user label exists, creating it if necessary. + * + * @param {string} labelName - Full label name (e.g. "01_Household/Primary") + * @param {Object} gmailApp - GAS GmailApp service (injected for testability) + * @returns {Object|null} GmailLabel or null on failure + */ +function ensureGmailLabel(labelName, gmailApp, gmailAdvanced) { + if ( + !gmailApp || + !labelName || + typeof gmailApp.getUserLabelByName !== 'function' + ) { + return null + } + const existing = gmailApp.getUserLabelByName(labelName) + if (existing) return existing + + if (typeof gmailApp.getUserLabels === 'function') { + const allLabels = gmailApp.getUserLabels() || [] + for (let i = 0; i < allLabels.length; i++) { + if ( + allLabels[i] && + typeof allLabels[i].getName === 'function' && + allLabels[i].getName().toLowerCase() === labelName.toLowerCase() + ) { + return allLabels[i] + } + } + } + + if (labelName.indexOf('/') !== -1) { + const parts = labelName.split('/') + let currentPath = '' + for (let p = 0; p < parts.length - 1; p++) { + currentPath = currentPath ? currentPath + '/' + parts[p] : parts[p] + ensureGmailLabel(currentPath, gmailApp, gmailAdvanced) + } + } + + try { + if (gmailAdvanced?.Users?.Labels?.create) { + try { + gmailAdvanced.Users.Labels.create( + { + name: labelName, + labelListVisibility: 'labelShow', + messageListVisibility: 'hide', + }, + 'me' + ) + const created = gmailApp.getUserLabelByName(labelName) + if (created) return created + } catch { + // Fall back to standard createLabel + } + } + return gmailApp.createLabel(labelName) + } catch (e) { + try { + if (typeof gmailApp.getUserLabels === 'function') { + const existingLabels = gmailApp.getUserLabels() || [] + for (let j = 0; j < existingLabels.length; j++) { + if ( + existingLabels[j] && + typeof existingLabels[j].getName === 'function' && + existingLabels[j].getName().toLowerCase() === + labelName.toLowerCase() + ) { + return existingLabels[j] + } + } + } + } catch {} + console.error( + '[ensureGmailLabel] Error creating label:', + labelName, + e.message + ) + return null } - return map[domain] || null } -function formatProgressiveDisclosureEntry( - dateStr, - title, - sender, - subject, - summaryText, - accountEmail +/** + * Creates a permanent Gmail filter rule mapping a sender address to a label. + * + * Returns true if the filter was created, false if the Advanced Gmail Service + * was unavailable or creation failed. + * + * @param {string} senderEmail - Sender address (raw, may include display name) + * @param {string} labelName - Target canonical label name + * @param {Object|null} gmailService - GAS Gmail advanced service (injected; may be null) + * @param {Object} gmailApp - GAS GmailApp service (injected for testability) + * @returns {boolean} + */ +function createPermanentGmailFilter( + senderEmail, + labelName, + gmailService, + gmailApp ) { - var entry = '\n### ' + dateStr + ' — ' + title + '\n' + if (!senderEmail || typeof senderEmail !== 'string') return false + if (!labelName || typeof labelName !== 'string') return false + const ltIdx = senderEmail.indexOf('<') + const gtIdx = senderEmail.indexOf('>', ltIdx + 1) + const cleanSender = + ltIdx !== -1 && gtIdx !== -1 + ? senderEmail.slice(ltIdx + 1, gtIdx).trim() + : senderEmail.trim() + const targetLabel = ensureGmailLabel(labelName, gmailApp) + if (!targetLabel) return false + + if (gmailService?.Users?.Settings?.Filters) { + try { + const existingFilters = gmailService.Users.Settings.Filters.list('me') + if (existingFilters?.filter) { + const duplicate = existingFilters.filter.some(function (f) { + return f.criteria?.from === cleanSender + }) + if (duplicate) { + console.log( + '[createPermanentGmailFilter] Filter already exists for label:', + labelName + ) + return true + } + } + } catch (e) { + console.error( + '[createPermanentGmailFilter] Error checking existing filters:', + e.message + ) + } + + const filterBody = { + criteria: { from: cleanSender }, + action: { addLabelIds: [targetLabel.getId()] }, + } + + try { + gmailService.Users.Settings.Filters.create(filterBody, 'me') + console.log( + '[createPermanentGmailFilter] Permanent filter created for label:', + labelName + ) + return true + } catch (e) { + console.error( + '[createPermanentGmailFilter] Error creating filter:', + e.message + ) + return false + } + } + + console.log( + '[createPermanentGmailFilter] Advanced Gmail service not enabled. Skipped.' + ) + return false +} + +/** + * Sets the Gmail system category tab (Primary, Updates, Promotions, Social, Forums) + * via the Advanced Gmail API. + * + * @param {Object} thread - GmailThread object + * @param {string} targetCategory - One of 'Primary', 'Updates', 'Promotions', 'Social', 'Forums' + * @param {Object} [gmailService] - Optional injected Advanced Gmail service (defaults to global Gmail) + */ +function setGmailCategoryTab(thread, targetCategory, gmailService) { + if (!thread || typeof thread.getId !== 'function') { + return + } + var gmail = gmailService || (typeof Gmail !== 'undefined' ? Gmail : null) + if ( + !gmail || + !gmail.Users || + !gmail.Users.Threads || + !gmail.Users.Threads.modify + ) { + return + } + + var catMap = { + Primary: 'CATEGORY_PERSONAL', + Updates: 'CATEGORY_UPDATES', + Promotions: 'CATEGORY_PROMOTIONS', + Social: 'CATEGORY_SOCIAL', + Forums: 'CATEGORY_FORUMS', + } + + var targetId = catMap[targetCategory] + if (!targetId) return + + var allCategoryIds = [ + 'CATEGORY_PERSONAL', + 'CATEGORY_UPDATES', + 'CATEGORY_PROMOTIONS', + 'CATEGORY_SOCIAL', + 'CATEGORY_FORUMS', + ] + + var removeIds = allCategoryIds.filter(function (id) { + return id !== targetId + }) + + try { + gmail.Users.Threads.modify( + { + addLabelIds: [targetId], + removeLabelIds: removeIds, + }, + 'me', + thread.getId() + ) + var threadSubject = + typeof thread.getFirstMessageSubject === 'function' + ? thread.getFirstMessageSubject() + : thread.getId() + console.log( + '[setGmailCategoryTab] Assigned category ' + + targetId + + ' to thread: ' + + threadSubject + ) + } catch (e) { + console.warn( + '[setGmailCategoryTab] Could not modify thread category: ' + e.message + ) + } +} + +/** + * Removes any pre-existing conflicting Core Domain labels, Sub-Labels, legacy flat labels, + * and redundant 'Retention/Permanent' tags to enforce a strict, clean label budget. + */ +function cleanConflictingLabels(thread, targetDomain, targetSubLabel, config) { + if (!thread || typeof thread.getLabels !== 'function') return + try { + var existingLabels = thread.getLabels() || [] + var canonicalDomains = (config && config.canonicalDomains) || [] + + var legacyFlatLabels = [ + 'Finance', + 'Household', + 'Family', + 'Projects', + 'Work', + 'Community', + 'Tech', + 'Purchases', + 'Banking', + 'Bills', + 'eBills', + 'Insurance', + 'Sent', + 'Archives', + ] + + var removeLabelSafe = function (lObj, msgPrefix) { + try { + thread.removeLabel(lObj) + console.log('[cleanConflictingLabels] ' + msgPrefix) + } catch (err) { + console.warn( + '[cleanConflictingLabels] Failed ' + msgPrefix + ': ' + err.message + ) + } + } + + for (var j = 0; j < existingLabels.length; j++) { + var lObj = existingLabels[j] + var lName = + typeof lObj.getName === 'function' ? lObj.getName() : String(lObj) + + // 1. Strip redundant Retention/Permanent (Inverted Model: blank = permanent) + if (lName === 'Retention/Permanent') { + removeLabelSafe(lObj, 'Stripped Retention/Permanent: ' + lName) + continue + } + + // 2. Protect valid short-lived retention expiration tags + if (lName.indexOf('Retention/') === 0) { + continue + } + + // 3. Protect global status and system labels + if (lName === ((config && config.processedLabel) || 'Processed')) { + continue + } + + // 4. Clean legacy flat labels + var isLegacy = false + for (var f = 0; f < legacyFlatLabels.length; f++) { + if (lName.toLowerCase() === legacyFlatLabels[f].toLowerCase()) { + removeLabelSafe(lObj, 'Stripped legacy flat label: ' + lName) + isLegacy = true + break + } + } + if (isLegacy) continue + + // 5. Clean ALL Core Domain labels (threads only carry Sub-Labels, never raw domain codes) + var isDomainCode = + isRootDomainLabel(lName) || canonicalDomains.indexOf(lName) !== -1 + if (isDomainCode) { + removeLabelSafe(lObj, 'Removed domain code label: ' + lName) + continue + } + + // 6. Clean conflicting sub-labels if they don't match targetSubLabel + if ( + lName.indexOf('/') !== -1 && + lName !== targetSubLabel && + lName.indexOf('Archives') === -1 && + lName.indexOf('Retention/') === -1 + ) { + removeLabelSafe(lObj, 'Removed conflicting sub-label: ' + lName) + } + } + } catch (e) { + console.warn('[cleanConflictingLabels] Error cleaning labels: ' + e.message) + } +} + +/** + * Extracts the normalized email address from a From header string. + * + * @param {string} sender - Raw sender header + * @returns {string} Lowercase email address or empty string + */ +function extractSenderAddress_(sender) { + const raw = String(sender || '') + .trim() + .toLowerCase() + const angleMatch = /<([^<>@\s]+@[^<>@\s]+)>/.exec(raw) + if (angleMatch) return angleMatch[1] + const plainMatch = /([^\s<>,"']+@[a-z0-9.-]+\.[a-z]{2,})/i.exec(raw) + return plainMatch ? plainMatch[1].toLowerCase() : '' +} + +/** + * Extracts the hostname portion of the sender's email address. + * + * @param {string} sender - Raw sender header + * @returns {string} Lowercase domain host or empty string + */ +function extractSenderHost_(sender) { + const addr = extractSenderAddress_(sender) + if (addr) { + const atIdx = addr.lastIndexOf('@') + return atIdx === -1 ? '' : addr.slice(atIdx + 1) + } + const raw = String(sender || '') + .trim() + .toLowerCase() + return /^[a-z0-9.-]+\.[a-z]{2,}$/.test(raw) ? raw : '' +} + +function toTermList_(items) { + if (!items) return [] + return Array.isArray(items) ? items : String(items).split('|') +} + +/** + * Checks whether the sender's hostname exactly matches or is a subdomain of + * any domain in the provided list. Prevents substring false positives (e.g. + * 'x.com' matching 'netflix.com' or 'fedex.com'). + * + * @param {string} sender - Raw sender header + * @param {string[]|string} domains - Candidate root/sub domains (array or pipe-delimited) + * @returns {boolean} True if sender host matches any domain + */ +function matchesSenderDomain_(sender, domains) { + const host = extractSenderHost_(sender) + const list = toTermList_(domains) + if (!host || list.length === 0) return false + for (let i = 0; i < list.length; i++) { + const target = String(list[i] || '') + .trim() + .toLowerCase() + if (target && (host === target || host.endsWith('.' + target))) { + return true + } + } + return false +} + +/** + * Checks whether the sender's email address exactly matches any address in the list. + * + * @param {string} sender - Raw sender header + * @param {string[]|string} addresses - Candidate email addresses (array or pipe-delimited) + * @returns {boolean} True if sender address matches + */ +function matchesSenderAddress_(sender, addresses) { + const addr = extractSenderAddress_(sender) + const list = toTermList_(addresses) + if (!addr || list.length === 0) return false + for (let i = 0; i < list.length; i++) { + const target = String(list[i] || '') + .trim() + .toLowerCase() + if (target && addr === target) return true + } + return false +} + +/** + * Checks whether the lowercased text contains any of the given substrings. + * + * @param {string} textLower - Lowercased hay-stack string + * @param {string[]|string} terms - Lowercase needle terms (array or pipe-delimited) + * @returns {boolean} True if any term is found + */ +function containsAny_(textLower, terms) { + const list = toTermList_(terms) + if (!textLower || list.length === 0) return false + for (let i = 0; i < list.length; i++) { + if (list[i] && textLower.indexOf(list[i]) !== -1) return true + } + return false +} + +/** + * Builds a normalized deterministic classification result object. + * + * @param {string|null} canonicalDomain - Canonical domain or null + * @param {string|null} subLabel - Sub-label or null + * @param {string} category - Inbox category ('Primary', 'Updates', 'Promotions', 'Social') + * @param {string} action - Action ('keep', 'archive', 'trash') + * @param {number} confidence - Confidence score (0.0 to 1.0) + * @param {string} subject - Email subject + * @param {string} summary - Classification summary + * @param {string} reasoning - Rule reasoning identifier + * @returns {Object} Classification result + */ +function makeDeterministicResult_( + canonicalDomain, + subLabel, + category, + action, + confidence, + subject, + summary, + reasoning +) { + return { + canonicalDomain: canonicalDomain, + canonical_label: canonicalDomain, + subLabel: subLabel, + category: category, + action: action, + confidence: confidence, + title: subject, + summary: summary, + reasoning: reasoning, + } +} + +const SCHOOL_DOMAINS_ = + 'bcsk12.org|bcs-lions.org|briarwoodchristianschool.org|mcaabhm.org|jeffersonstate.edu' + +const SCHOOL_BROADCAST_TERMS_ = + 'weekly|newsletter|bulletin|school update|campus update|district update|bcs update|lions update|digest|progress is ready|lunch|menu|portraits|news|noteworthy|announcement|athletics|e learning|e-learning|report card update|trunk or treat|spirit week|pto|pta|open house|picture day|fall festival|spring festival|school announcement|tryouts|tryout|try-outs|try-out|auditions|audition|team tryouts|absent|attendance|tardy|tuition|billing statement|assignment posted|new assignment|grade updated|score published|family portal|parent portal' + +const CAREER_SUBJECT_TERMS_ = + 'employment agreement|employment contract|offer letter|job offer|employment offer|terms of employment|independent contractor agreement|restrictive covenant|confidentiality agreement|interview confirmation|interview invitation|invitation to interview|interview with |schedule an interview|recruiter screen|phone screen' + +const CAREER_SNIPPET_TERMS_ = + 'employment agreement|offer of employment|terms of employment|pleased to offer you the position|pleased to offer you|pleased to confirm our offer|congratulations on your offer|employment document|restrictive covenants agreement' + +const CUSTOM_RULE_MATCHERS_ = { + nonprofit: function (ctx) { + return ( + matchesSenderDomain_( + ctx.sender, + 'ignite-hope.org|helpingoneguy.com|charitywater.org|compassion.com|worldvision.org' + ) || + containsAny_(ctx.subLower, 'ironmen|iron men') || + (containsAny_(ctx.sLower, 'newsletter|update|impact') && + containsAny_( + ctx.subLower, + 'impact update|ministry update|community update' + )) + ) + }, + saasPlan: function (ctx) { + return ( + containsAny_( + ctx.subLower, + 'trial is ending|trial ending|trial will expire|trial expired|trial expiration|free trial is ending|free trial ends' + ) || + (ctx.subLower.indexOf('aws') !== -1 && + containsAny_( + ctx.subLower, + 'upgraded to a paid plan|free plan is ready' + )) || + ((matchesSenderDomain_( + ctx.sender, + 'aws.com|amazonaws.com|digitalocean.com|cloudflare.com|openai.com|anthropic.com|supabase.com|neon.tech' + ) || + ctx.sLower.indexOf('azure') !== -1) && + containsAny_( + ctx.subLower, + 'upgraded to a paid plan|free plan is ready|free tier|startup credits|account plan' + )) + ) + }, + schoolBroadcast: function (ctx) { + const isParentSquare = matchesSenderDomain_(ctx.sender, 'parentsquare.com') + return ( + ctx.isBrightArrow || + ((ctx.isSchoolDomain || + isParentSquare || + matchesSenderAddress_(ctx.sender, 'admissions@ua.edu')) && + (ctx.isBroadcastSubject || isParentSquare)) + ) + }, + schoolTravel: function (ctx) { + return ( + matchesSenderDomain_( + ctx.sender, + 'ef.com|eftours.com|worldstrides.com|explorica.com|acis.com' + ) || + (containsAny_(ctx.subLower, 'trip to |school travel|educational tour') && + containsAny_( + ctx.subLower, + 'interest meeting|informational meeting|rsvp|enrollment discount' + )) + ) + }, + bankRate: function (ctx) { + return ( + containsAny_( + ctx.subLower, + 'higher apy|apy has increased|apy increased|earns a higher apy|rate increase|rate decrease|rate update|rates are changing|yield update|interest rate increase|savings rate increase|annual percentage yield' + ) || + (ctx.subLower.indexOf('rate change') !== -1 && + containsAny_(ctx.subLower, 'apy|cash|savings|interest')) + ) + }, +} + +/** + * Declarative deterministic rule specifications. + * Format: mode::domains::addresses::senderTerms::subjectTerms::excludeSubject::domain::subLabel::category::action::confidence::reasoning::summary + */ +const DETERMINISTIC_RULE_SPECS_ = [ + 'any::::::::your day ahead::::01_Household::Household/Property::Updates::archive::0.99::Daily Digest::Automated daily agenda digest routed to Updates.', + 'sender+sub::amazon.com|hp.com|brother.com|canon.com|epson.com::auto-reorder@amazon.com|dash@amazon.com::instantink::time to order the|time to reorder|smart reorder|auto-reorder|replenishment|low ink|ink alert|toner low|toner alert|toner for your|replacement filter|reorder your::::01_Household::Household/Supplies::Updates::archive::0.99::Smart Reorder::Automated smart reorder or consumable replenishment alert routed to Updates.', + 'any::broodminder.com|mybroodminder.com::::broodminder|mybroodminder::hive alert|events in your apiaries|events in your apiary|event in your apiaries|event in your apiary|apiary alert|apiary event::::07_Community_NonProfit::Projects/Telemetry::Updates::archive::0.99::BroodMinder Telemetry::Automated hive and apiary telemetry alert routed to Projects/Telemetry.', + 'any::qnap.com::::qts notification|notificationcenter::[qnap|qts notification|qvr pro|qulog::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::QNAP Telemetry::QNAP NAS system health, RAID, backup, or QVR surveillance telemetry alert routed to Tech/Alerts.', + 'any::ui.com|ubnt.com::::::unifi::verification code|mfa|one-time|security code::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::UniFi Telemetry::UniFi Dream Machine or network infrastructure alert routed to Tech/Alerts.', + 'any::nabucasa.com|home-assistant.io::::::home assistant::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::Home Assistant Telemetry::Home Assistant or Nabu Casa cloud telemetry/subscription alert routed to Tech/Alerts.', + 'custom:nonprofit::::::::::::07_Community_NonProfit::NonProfit/General::Updates::archive::0.98::NonProfit Impact Newsletter::Community non-profit organization newsletter, Bible study digest, or project impact update routed to Updates.', + 'any::linear.app::::::::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::Linear Alert::Linear task and project notification digest routed to Tech/Alerts.', + 'any::notifications.cubic.dev|cubic.dev::::cubic::::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::Cubic Code Review::Cubic AI automated code review notice routed to Tech/Alerts.', + 'any::::notifications@github.com::::::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::GitHub Alert::GitHub CI/CD workflow run or notification routed to Tech/Alerts.', + 'any::sonarcloud.io|sentry.io|vercel.com::noreply-apps-scripts-notifications@google.com::::::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::Developer Monitoring Alert::CI/CD code quality, error monitoring, or deployment alert routed to Tech/Alerts.', + 'any::::googleaistudio-noreply@google.com::::::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::Google AI Studio Alert::Google AI Studio API quota, model deprecation, or project update routed to Tech/Alerts.', + 'any::updates.codeant.ai|codeant.ai::::::::::::::Promotions::archive::0.98::DevTool Promo::Developer tooling promotional or product feature announcement routed to Promotions.', + 'any::::calendar-notification@google.com::::::::01_Household::Household/Property::Updates::archive::0.99::Calendar Notification::Google Calendar event reminder notification routed to Updates.', + 'custom:saasPlan::::::::::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::SaaS / Cloud Plan Alert::Cloud provider tier status, SaaS trial expiration, or account plan notification routed to Tech/Alerts.', + 'any::pypi.org|npmjs.com|docker.com|crates.io::::::[pypi]|trusted publisher::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::Package Registry Alert::Package repository publishing, release, or trusted publisher notification routed to Tech/Alerts.', + 'any::::::::you shared some google account data with|google account data with|granted access to your google account|third-party access|app access granted::::05_Tech_Infrastructure::Tech/Security::Primary::keep::1::Third-Party Authorization Shield::Third-party application account data sharing or authorization notice preserved in Primary Inbox as security warning.', + 'any::::::::verification code|security code|one-time passcode|one-time password|your login code|new sign-in|signed in from a new|new device signed in|sign-in from new device|security alert for your|password changed|password reset confirmation::::05_Tech_Infrastructure::Tech/Security::Updates::archive::0.99::Authentication/Sign-in Ping::Automated authentication verification code or device sign-in alert routed to Tech/Security.', + 'sender+sub::cvs.com|walgreens.com|questdiagnostics.com|labcorp.com|zocdoc.com|athenahealth.com::::mychart|cvshealth::appointment reminder|upcoming appointment|appointment confirmation|appointment is confirmed|prescription is ready|prescription ready|refill reminder|refill confirmation::action required|abnormal|urgent|past due|overdue::04_Family_Health::Family/Health-Medical::Updates::archive::0.99::Healthcare/Pharmacy Alert::Automated patient portal appointment reminder or pharmacy prescription status notice.', + 'sender+sub::delta.com|southwest.com|aa.com|united.com|airbnb.com|vrbo.com|marriott.com|hilton.com|uber.com|lyft.com|waymo.com::::waymo::flight confirmation|flight status|boarding pass|check in for your flight|reservation confirmed|reservation confirmation|upcoming stay|trip with uber|ride with lyft|waymo ride|riding with waymo|receipt::::01_Household::Household/Travel::Updates::archive::0.99::Travel Confirmation::Travel itinerary reservation, flight check-in, or rideshare receipt.', + 'any::::::::how was your experience|how did we do|rate your recent|rate your experience|rate your stay|tell us what you think|take our survey|take our quick survey|quick survey|feedback requested|review your purchase|leave a review|your opinion matters::::::::Promotions::archive::0.99::Customer Survey::Automated post-transaction customer satisfaction feedback or survey request.', + 'any::ifttt.com|notify.mios.com::::::::::01_Household::Household/Property::Updates::archive::0.98::IFTTT / Weather Alert::Automated weather or IoT trigger notification routed to Household/Property.', + 'custom:schoolBroadcast::::::::::::04_Family_Health::Family/School-Student::Updates::archive::0.98::School Broadcast::Routine school bulletin, newsletter, BrightArrow broadcast, or ParentSquare digest routed to Updates.', + 'custom:schoolTravel::::::::::::04_Family_Health::Family/School-Student::Promotions::archive::0.99::School Travel Solicitation::Educational travel program solicitation or informational trip meeting announcement routed to Promotions.', + 'any::hello.springhealth.com|springhealth.com::::::::::04_Family_Health::Family/Health-Medical::Updates::archive::0.98::Benefits Broadcast::Routine employer healthcare benefit announcement or wellness newsletter.', + 'sender+sub::ebay.com::::::delivered|out for delivery|order confirmed|shipped::::02_Finance_Legal::Finance/Purchases::Updates::archive::0.98::Order Tracking::Online order confirmation or package delivery tracking notice.', + 'sender+sub::ups.com|fedex.com|usps.com|usps.gov::::::delivered|out for delivery|tracking|scheduled delivery|package|shipment::::02_Finance_Legal::Finance/Purchases::Updates::archive::0.98::Carrier Tracking::Carrier parcel shipment or package delivery notification.', + 'sender+sub::amazon.com::::::ordered:|shipped:|delivered:|refund issued|auto-reloaded::::02_Finance_Legal::Finance/Purchases::Updates::archive::0.98::Amazon Routine::Routine Amazon e-commerce order, shipping, or refund update.', + 'sender+sub::chase.com|bankofamerica.com|spireenergy.com|alabamapower.com|bwwb.org|message.att-mail.com|att-mail.com|citi.com|americanexpress.com|apcocu.org|rocketmortgage.com|mycirclecard.target.com::::capitalone|spectrum|citicards::statement|autopay|payment processed|payment confirmation|payment scheduled|payment received|scheduled payment|bill is ready|bill is available|e-statement|estatement::failed|failure|declined|past due|overdue|action required|unpaid|late fee|returned payment|insufficient funds::02_Finance_Legal::Finance/Bills::Updates::archive::0.98::Routine Billing::Routine billing statement, receipt, or AutoPay confirmation.', + 'sender+sub::bcbsal.org|bcbs.com|uhc.com|aetna.com|cigna.com|humana.com::::bluecrosspayments|unitedhealthcare::explanation of benefits|claim|claims|eob::::02_Finance_Legal::Finance/Insurance::Updates::archive::0.99::Insurance Claim / EOB Notice::Health insurance claim processing notice or explanation of benefits (EOB) notification routed to Finance/Insurance.', + 'sender+sub::bcbsal.org|bcbs.com|uhc.com|aetna.com|cigna.com|humana.com::::bluecrosspayments|unitedhealthcare::payment|payments|premium|statement|bill|processed|receipt::failed|failure|declined|past due|overdue|action required|unpaid|late fee|returned payment|insufficient funds::02_Finance_Legal::Finance/Bills::Updates::archive::0.99::Insurance Payment Receipt::Health insurance premium payment or processing notification routed to Finance/Bills.', + 'sender+sub::geico.com|progressive.com|statefarm.com::::::auto pay|autopay|payment|bill|statement|policy document|id card::claim|adjuster|settlement|complaint|failed|failure|declined|past due|overdue|action required|unpaid|late fee|returned payment|insufficient funds::02_Finance_Legal::Finance/Bills::Updates::archive::0.99::Auto Insurance Routine Billing::Routine auto insurance billing, AutoPay notice, or policy document routed to Finance/Bills.', + 'sender+sub::chase.com|discover.com::::creditwise|creditkarma|experian|equifax|transunion|myfico|capitalone::credit score|score went up|score went down|creditwise|score changed|credit alert|credit monitoring|credit report|fico score|new credit inquiry::::02_Finance_Legal::Finance/Banking::Updates::archive::0.99::Credit Score Alert::Automated credit score update or credit monitoring notification routed to Updates.', + 'custom:bankRate::::::::::::02_Finance_Legal::Finance/Banking::Updates::archive::0.99::Bank Rate Update::Automated bank account APY or interest rate change notification routed to Finance/Banking.', + 'any::::::::terms of service|privacy policy|terms and conditions|terms of use|user agreement|privacy notice|policy update|updates to our terms|updates to our privacy|arbitration clause|arbitration terms|consumer privacy::::::::Updates::archive::0.99::Policy / Terms Update::Commercial platform terms of service, privacy policy revision, or user agreement update routed to Updates.', + 'sender+sub::paypal.com::::::receipt|payment|transaction|you sent|you paid|you received|authorized|usd::::02_Finance_Legal::Finance/Purchases::Updates::archive::0.98::Payment Receipt::Electronic payment transaction receipt or transfer confirmation.', + 'sender+sub::homedepot.com::::::order|receipt|picked up|ready for pickup::::02_Finance_Legal::Finance/Purchases::Updates::archive::0.98::Retail Order / Receipt::Home Depot store receipt or order pickup confirmation.', + 'sender+sub::target.com|costco.com|bestbuy.com|walmart.com::::::order|receipt|ready for pickup|picked up|shipped|delivered::::02_Finance_Legal::Finance/Purchases::Updates::archive::0.98::Retail Order / Receipt::Retail store order confirmation, receipt, or pickup notice.', + 'sender+sub::ereceiptshell.com|autozone.com|advanceautoparts.com|rockauto.com::::shell e-receipt::receipt|purchase|order|fuel::::03_Vehicles::Vehicles/Maintenance::Updates::archive::0.99::Vehicle Fuel/Maintenance Receipt::Automated vehicle fuel purchase receipt or parts order confirmation routed to Vehicles/Maintenance.', + 'any::nytimes.com|nextdoor.com|substack.com|redditmail.com|daily.therundown.ai|therundown.ai|mail.instagram.com|instagram.com|facebookmail.com|linkedin.com|pinterest.com|quora.com|youtube.com|twitter.com|x.com::::dougforalabama|ossoff|the passion translation|aibreakfast::::::::::Social::archive::0.98::News/Newsletter/Social::Routine media newsletter, Substack digest, social media alert, or political appeal.', +] + +function evaluateDeterministicRuleSpec_(specStr, ctx, subject) { + const parts = specStr.split('::') + const mode = parts[0] + const excludeSubject = parts[5] + if (excludeSubject && containsAny_(ctx.subLower, excludeSubject)) { + return null + } + let matched = false + if (mode.indexOf('custom:') === 0) { + matched = CUSTOM_RULE_MATCHERS_[mode.slice(7)](ctx) + } else { + const senderHit = + matchesSenderDomain_(ctx.sender, parts[1]) || + matchesSenderAddress_(ctx.sender, parts[2]) || + containsAny_(ctx.sLower, parts[3]) + const subjectHit = containsAny_(ctx.subLower, parts[4]) + matched = + mode === 'sender+sub' ? senderHit && subjectHit : senderHit || subjectHit + } + if (!matched) return null + return makeDeterministicResult_( + parts[6] || null, + parts[7] || null, + parts[8], + parts[9], + Number.parseFloat(parts[10]), + subject, + parts[12], + parts[11] + ) +} + +/** + * Fast-path deterministic rule-based classifier. + * Categorizes unambiguous broadcast feeds, machine telemetry, routine receipts, + * and social newsletters in 0ms without consuming Gemini API quota. + * Strictly respects safety shields (user-sent, family, and 1:1 teacher correspondence). + * + * @param {string} sender - Email sender header + * @param {string} subject - Email subject line + * @param {string} snippet - Email body snippet + * @param {Object} [thread] - GmailThread object (for sender/reply safety checks) + * @param {Object} [config] - Classifier configuration + * @returns {Object|null} Classification object or null if email requires Gemini LLM reasoning + */ +function deterministicRuleClassify_(sender, subject, snippet, thread, config) { + const sLower = (sender || '').toLowerCase() + const subLower = (subject || '').toLowerCase() + const snipLower = (snippet || '').toLowerCase() + const userEmail = ( + config && config.userAccountEmail ? config.userAccountEmail : '' + ).toLowerCase() + + // 1. Safety Shields + if (thread && typeof thread.getMessages === 'function' && userEmail) { + try { + const msgs = thread.getMessages() || [] + for (let m = 0; m < msgs.length; m++) { + const fromA = ( + msgs[m] && typeof msgs[m].getFrom === 'function' + ? msgs[m].getFrom() + : (msgs[m] && msgs[m].from) || '' + ).toLowerCase() + if (fromA.indexOf(userEmail) !== -1) { + return makeDeterministicResult_( + null, + null, + 'Primary', + 'keep', + 1.0, + subject, + 'User-sent or replied thread preserved in Primary Inbox.', + 'User-Sent Shield' + ) + } + } + } catch { + // ignore thread check errors + } + } + + // Family 1:1 Shield (configurable via config.familyEmails or customPromptRules) + const familyList = + config && config.familyEmails + ? config.familyEmails.toLowerCase().split(',') + : [] + if ( + familyList.length > 0 && + familyList.some(function (f) { + return f.trim() && sLower.indexOf(f.trim()) !== -1 + }) + ) { + return makeDeterministicResult_( + '04_Family_Health', + 'Family/Correspondence', + 'Primary', + 'keep', + 1.0, + subject, + 'Direct family correspondence preserved in Primary Inbox.', + 'Family 1:1 Shield' + ) + } + + // Teacher / Academic Counselor 1:1 Shield (exclude mass broadcast subjects) + const isSchoolDomain = matchesSenderDomain_(sender, SCHOOL_DOMAINS_) + const isBrightArrow = + matchesSenderDomain_(sender, ['brightarrow.com']) || + sLower.indexOf('brightarrow') !== -1 + const isBroadcastSubject = containsAny_(subLower, SCHOOL_BROADCAST_TERMS_) + + if (isSchoolDomain && !isBroadcastSubject && !isBrightArrow) { + return makeDeterministicResult_( + '04_Family_Health', + 'Family/School-Student', + 'Primary', + 'keep', + 1.0, + subject, + 'Direct 1:1 teacher or school counselor correspondence preserved in Primary Inbox.', + 'Teacher 1:1 Shield' + ) + } + + // 06_Work_Career: Employment Agreements, Offer Letters, Employment Contracts, Recruiter Screens & Interviews + if ( + containsAny_(subLower, CAREER_SUBJECT_TERMS_) || + containsAny_(snipLower, CAREER_SNIPPET_TERMS_) + ) { + return makeDeterministicResult_( + '06_Work_Career', + 'Work/Career', + 'Primary', + 'keep', + 0.99, + subject, + 'Employment agreement, offer letter, or career interview communication preserved in Primary Inbox.', + 'Career / Employment Contract Rule' + ) + } + + // 2. Deterministic Feeds & Broadcasts + const ruleContext = { + sender: sender, + sLower: sLower, + subLower: subLower, + snipLower: snipLower, + isSchoolDomain: isSchoolDomain, + isBrightArrow: isBrightArrow, + isBroadcastSubject: isBroadcastSubject, + } + for (let r = 0; r < DETERMINISTIC_RULE_SPECS_.length; r++) { + const ruleHit = evaluateDeterministicRuleSpec_( + DETERMINISTIC_RULE_SPECS_[r], + ruleContext, + subject + ) + if (ruleHit) return ruleHit + } + + // Retail & E-Commerce Marketing Blasts + const isScarsella = sLower.indexOf('scarsella') !== -1 + const isScarsellaCurriculum = + isScarsella && + /\b(curriculum|schedule|testing|belt|forms?)\b/i.test( + (subject || '') + ' ' + (snippet || '') + ) + + const isPromoSubject = + /\b(sale|\d+%\s*off|deal of the day|daily deals?|clearance|limited time offer|coupon|promo code|shop now|shop today|exclusive offer|special offer|flash sale|black friday|cyber monday|memorial day sale|labor day sale|presidents day sale|discount|save \$\d+|save up to|free shipping|buy one get one|bogo|don't miss out|last chance to save|doorbusters?|rewards balance|earn bonus points|member exclusive|weekly ad|new arrivals)\b/i.test( + (subject || '') + ' ' + (snippet || '') + ) + const isMarketingSender = + /\b(promotions|promo|deals|marketing|offers|newsletter|news)@/i.test(sLower) + const isNonTransactional = !containsAny_( + subLower, + 'order|receipt|invoice|statement|autopay|tracking|shipped|delivered|action required|verify|security|support|case|ticket|return|refund|appointment|recall|warranty' + ) + + const isRetailVendor = + matchesSenderDomain_( + sender, + 'target.com|costco.com|bestbuy.com|walmart.com|doordash.com|kohls.com|samsclub.com|em1.cloudflare.com' + ) || + containsAny_( + sLower, + 'lowes|mannlake|urotuning|ray-ban|americanmeadows|foxhound|bhphotovideo|starbucks|birminghamhomebuyers|trailerparts|hillco' + ) + + if ( + isNonTransactional && + (isPromoSubject || + (isRetailVendor && isMarketingSender) || + (isScarsella && !isScarsellaCurriculum)) + ) { + return makeDeterministicResult_( + null, + null, + 'Promotions', + 'archive', + 0.98, + subject, + 'Commercial vendor retail promo, discount, or marketing announcement.', + 'Retail Promo' + ) + } + + return null +} + +/** + * Processes a batch of Gmail threads: classify, label, and optionally create filters. + * + * @param {Object[]} threads - Array of GmailThread objects + * @param {Object} config - Classifier configuration + * @param {Object} services - Injected GAS services: { GmailApp, UrlFetchApp, Gmail } + * @returns {Object[]} Array of result objects describing outcome per thread + */ +function processThreadBatch(threads, config, services) { + const processedLabel = ensureGmailLabel( + config.processedLabel, + services.GmailApp + ) + if (!processedLabel) { + console.error( + '[processThreadBatch] Processed label unavailable; aborting batch to avoid reprocessing.' + ) + return [] + } + const results = [] + + threads.forEach(function (thread) { + const threadId = + typeof thread.getId === 'function' + ? thread.getId() + : thread.id || 'unknown' + const messages = + typeof thread.getMessages === 'function' ? thread.getMessages() : [] + if (!messages || messages.length === 0) { + results.push({ threadId: threadId, status: 'empty' }) + return + } + + const latestMsg = messages[0] + const sender = + typeof latestMsg.getFrom === 'function' + ? latestMsg.getFrom() + : latestMsg.from || '' + const subject = + typeof latestMsg.getSubject === 'function' + ? latestMsg.getSubject() + : latestMsg.subject || '' + let bodySnippet = + typeof latestMsg.getPlainBody === 'function' + ? latestMsg.getPlainBody().substring(0, 1200) + : latestMsg.snippet || '' + bodySnippet = enrichSnippetWithAttachments_(bodySnippet, latestMsg) + + let classification = deterministicRuleClassify_( + sender, + subject, + bodySnippet, + thread, + config + ) + if (!classification) { + classification = classifyEmailWithGemini( + config, + sender, + subject, + bodySnippet, + services.UrlFetchApp + ) + } + if (!classification) { + console.log('[processThreadBatch] Could not classify thread:', threadId) + results.push({ threadId: threadId, status: 'unclassified' }) + return + } + + console.log( + '[processThreadBatch] Classified as:', + classification.canonical_label, + '(Confidence:', + classification.confidence, + ')' + ) + + var primaryTag = classification.subLabel + if (!primaryTag || isRootDomainLabel(primaryTag)) { + var dom = classification.canonicalDomain || classification.canonical_label + primaryTag = (dom && DEFAULT_DOMAIN_SUBLABEL_MAP[dom]) || dom || null + } + + if (typeof cleanConflictingLabels === 'function') { + cleanConflictingLabels( + thread, + classification.canonicalDomain || classification.canonical_label, + primaryTag, + config + ) + } + + if (primaryTag) { + const categoryLabel = ensureGmailLabel( + primaryTag, + services.GmailApp, + services.Gmail + ) + if (!categoryLabel) { + console.error( + '[processThreadBatch] Failed to create category label:', + primaryTag + ) + results.push({ + threadId: threadId, + status: 'label_creation_failed', + }) + return + } + thread.addLabel(categoryLabel) + } + thread.addLabel(processedLabel) + + // Category-Driven Action Invariant: + // Primary belongs in the Action Queue (Inbox). + // Non-Primary categories (Updates, Promotions, Social, Forums) are Informational Streams + // that deterministically bypass the Primary Inbox into their category tab and domain sub-label. + if ( + classification.category && + classification.category !== 'Primary' && + classification.action !== 'trash' + ) { + classification.action = 'archive' + } + + if ( + classification.category && + classification.action !== 'trash' && + typeof setGmailCategoryTab === 'function' + ) { + setGmailCategoryTab(thread, classification.category, services.Gmail) + } + + if ( + classification.action === 'archive' && + typeof thread.moveToArchive === 'function' + ) { + thread.moveToArchive() + } + + let filterCreated = false + if (classification.confidence >= config.autoFilterConfidenceThreshold) { + filterCreated = createPermanentGmailFilter( + sender, + classification.canonical_label, + services.Gmail, + services.GmailApp + ) + } + + let savedAttachments = [] + if (services && services.DriveApp) { + savedAttachments = persistCanonicalAttachmentsToDrive( + thread, + classification, + config, + services + ) + } + + results.push({ + threadId: threadId, + status: 'classified', + label: classification.canonical_label, + confidence: classification.confidence, + filterCreated: filterCreated, + savedAttachments: savedAttachments, + }) + }) + + return results +} + +const RULE6_PATTERNS = [ + [/\S \? \S/, "' ? ' between words (was an em dash or a · separator)"], + [/\?\?/, "'??' (was a multi-codepoint emoji)"], + [/[A-Za-z]\?[A-Za-z]/, "'?' inside a word (was a curly apostrophe)"], + [/\uFFFD/, 'U+FFFD replacement character'], +] + +/** + * Resolves the target GitHub repository owner and name from injected services, + * environment variables, ScriptProperties, or config. + * + * @param {Object} [services] - Optional injected services + * @returns {{ repoOwner: string, repoName: string }} + */ +function resolveGitHubRepoTarget_(services) { + const props = + services?.PropertiesService || + (typeof PropertiesService !== 'undefined' ? PropertiesService : null) + const scriptProps = props?.getScriptProperties?.() + const cfg = + typeof getAiClassifierConfig === 'function' ? getAiClassifierConfig() : {} + const envOwner = + typeof process !== 'undefined' && process.env + ? process.env.GITHUB_REPO_OWNER + : '' + const envName = + typeof process !== 'undefined' && process.env + ? process.env.GITHUB_REPO_NAME + : '' + + const repoOwner = + services?.githubRepoOwner || + envOwner || + scriptProps?.getProperty?.('GITHUB_REPO_OWNER') || + cfg.githubRepoOwner || + '' + const repoName = + services?.githubRepoName || + envName || + scriptProps?.getProperty?.('GITHUB_REPO_NAME') || + cfg.githubRepoName || + '' + + return { repoOwner, repoName } +} + +/** Refuse to write an entry that already shows mojibake. */ +function assertClean_(text, what) { + if (!text) return + const scrubbed = String(text).replace(/https?:\/\/\S+/gi, '') + for (let i = 0; i < RULE6_PATTERNS.length; i++) { + if (RULE6_PATTERNS[i][0].test(scrubbed)) { + throw new Error( + 'Rule 6: refusing to write ' + what + ' — ' + RULE6_PATTERNS[i][1] + ) + } + } +} + +/** Refuse to write when a non-ASCII char in the source text became '?' on the way out. */ +function assertNoAsciiReplacement_(source, rendered) { + if (!source || !rendered) return + if (rendered.indexOf('?') === -1) return + const lost = [] + for (let i = 0; i < source.length; i++) { + const c = source.charAt(i) + if ( + c.charCodeAt(0) > 127 && + rendered.indexOf(c) === -1 && + lost.indexOf(c) === -1 + ) { + lost.push(c) + } + } + if (lost.length) { + throw new Error( + 'Rule 6: refusing to write text that flattened non-ASCII to "?": ' + + lost.join(' ') + + ' — encode as UTF-8, not ASCII.' + ) + } +} + +function extractTopicTitleFromPath(filePath) { + if (!filePath || typeof filePath !== 'string') { + return 'Ingested Records' + } + const trimmed = filePath.trim() + if (!trimmed || trimmed === '/') { + return 'Ingested Records' + } + const parts = trimmed.split('/').filter(Boolean) + if (parts.length === 0) { + return 'Ingested Records' + } + const topic = parts.length > 1 ? parts[parts.length - 2] : parts[0] + // Strip any trailing file extension (e.g. ".md") before title-casing so a + // single-segment path like "digital-backups.md" becomes "Digital Backups". + const cleaned = topic.replace(/\.[^.]+$/, '').replace(/-/g, ' ') + if (!cleaned) return 'Ingested Records' + return cleaned.replace(/\b\w/g, function (l) { + return l.toUpperCase() + }) +} + +function insertEntryIntoLogSection(fullContent, newEntry) { + if (!fullContent && !newEntry) return '' + if (!fullContent) return String(newEntry) + if (!newEntry) return String(fullContent) + + const detailsMarker = '</details>' + const detailsIndex = fullContent.indexOf(detailsMarker) + + if (detailsIndex !== -1) { + return ( + fullContent.substring(0, detailsIndex) + + newEntry + + '\n' + + fullContent.substring(detailsIndex) + ) + } + + const section3Marker = '## 3. Ingested Activity' + const section3Index = fullContent.indexOf(section3Marker) + + if (section3Index !== -1) { + const lineBreakIndex = fullContent.indexOf('\n', section3Index) + return ( + fullContent.substring(0, lineBreakIndex + 1) + + newEntry + + '\n' + + fullContent.substring(lineBreakIndex + 1) + ) + } + + return fullContent + '\n' + newEntry +} + +function formatProgressiveDisclosureEntry( + dateStr, + title, + sender, + subject, + summaryText, + accountEmail, + attachments +) { + let entry = '\n### ' + dateStr + ' — ' + title + '\n' entry += '- **Account**: ' + accountEmail + '\n' entry += '- **From**: ' + sender + '\n' entry += '- **Subject**: ' + subject + '\n' if (summaryText) { entry += '- **Summary**:\n > ' + summaryText.trim() + '\n' } - return entry + if (attachments && attachments.length > 0) { + entry += '- **Attachments**:\n' + for (let a = 0; a < attachments.length; a++) { + const att = attachments[a] + if (att?.name) { + if (att.url) { + entry += ' - [' + att.name + '](' + att.url + ')\n' + } else { + entry += ' - ' + att.name + '\n' + } + } + } + } + return entry +} + +function getNotePathForDomain(domain, _subLabel) { + const map = { + '01_Household': '01_Household/index.md', + '02_Finance_Legal': '02_Finance_Legal/index.md', + '03_Vehicles': '03_Vehicles/index.md', + '04_Family_Health': '04_Family_Health/index.md', + '05_Tech_Infrastructure': '05_Tech_Infrastructure/index.md', + '06_Work_Career': '06_Work_Career/index.md', + '07_Community_NonProfit': '07_Community_NonProfit/index.md', + } + if (typeof PropertiesService !== 'undefined') { + try { + const customMapJson = + PropertiesService.getScriptProperties().getProperty('CUSTOM_NOTE_PATHS') + if (customMapJson) { + const customMap = JSON.parse(customMapJson) + if (customMap && customMap[domain]) { + return customMap[domain] + } + } + } catch { + // Fall through to default map + } + } + return map[domain] || null +} + +function appendMarkdownEntryToGitHubRepo( + filePath, + entryMd, + commitMessage, + services +) { + const props = + services?.PropertiesService || + (typeof PropertiesService !== 'undefined' ? PropertiesService : null) + const githubToken = props?.getScriptProperties()?.getProperty('GITHUB_PAT') + if (!githubToken) { + console.log( + '[gitHubSync] GITHUB_PAT ScriptProperty not set. Skipping GitHub commit.' + ) + return false + } + + const sleep = services?.sleepFn || _sleep + const maxRetries = 3 + for (let attempt = 1; attempt <= maxRetries; attempt++) { + const result = executeGitHubCommit( + filePath, + entryMd, + commitMessage, + githubToken, + services + ) + if (result === true || result === 'IDEMPOTENT_SKIP') { + return true + } + console.log( + '[gitHubSync] Retry attempt', + attempt, + 'of', + maxRetries, + 'for', + filePath + ) + sleep(1000 * attempt) + } + + console.error( + '[gitHubSync] Failed to commit entry to GitHub after', + maxRetries, + 'attempts:', + filePath + ) + return false +} + +function executeGitHubCommit( + filePath, + entryMd, + commitMessage, + githubToken, + services +) { + if (!filePath || !entryMd) { + console.warn('[gitHubSync] Missing filePath or entryMd for GitHub commit.') + return false + } + const urlFetchApp = + services?.UrlFetchApp || + (typeof UrlFetchApp !== 'undefined' ? UrlFetchApp : null) + const utils = + services?.Utilities || (typeof Utilities !== 'undefined' ? Utilities : null) + + const { repoOwner, repoName } = resolveGitHubRepoTarget_(services) + if (!repoOwner || !repoName) { + console.warn( + '[gitHubSync] GITHUB_REPO_OWNER or GITHUB_REPO_NAME not configured. Skipping GitHub commit.' + ) + return false + } + + const encodedPath = String(filePath) + .split('/') + .filter(Boolean) + .map(function (segment) { + return encodeURIComponent(segment) + }) + .join('/') + + const url = + 'https://api.github.com/repos/' + + encodeURIComponent(repoOwner) + + '/' + + encodeURIComponent(repoName) + + '/contents/' + + encodedPath + const headers = { + Authorization: 'Bearer ' + githubToken, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'Google-Apps-Script', + } + + try { + const getOptions = { + method: 'get', + headers: headers, + muteHttpExceptions: true, + } + const res = urlFetchApp.fetch(url, getOptions) + const statusCode = res.getResponseCode() + + let sha = null + let rawContent = '' + + if (statusCode === 404) { + console.log( + '[gitHubSync] File not found on GitHub (HTTP 404). Initializing new note:', + filePath + ) + const topicTitle = extractTopicTitleFromPath(filePath) + const dateStr = utils?.formatDate + ? utils.formatDate(new Date(), 'GMT', 'yyyy-MM-dd') + : new Date().toISOString().slice(0, 10) + rawContent = + '---\ntitle: ' + + topicTitle + + '\ncreated: ' + + dateStr + + '\nnotebook: household\nsection: general\n---\n\n' + + '# ' + + topicTitle + + '\n\n' + + '## 1. Executive Summary & Active Status\n- Ingested records log.\n\n' + + '## 2. Key References & Quick Links\n| Topic | Asset |\n| :--- | :--- |\n\n' + + '## 3. Ingested Activity Logs\n<details open><summary><b>Activity Logs</b></summary>\n</details>\n' + } else if (statusCode === 200) { + const fileData = JSON.parse(res.getContentText()) + sha = fileData.sha + // GitHub Contents API wraps Base64 in newlines every 60 chars; strip them + // before decoding since some decoders reject embedded whitespace. + rawContent = utils + .newBlob( + utils.base64Decode((fileData.content || '').replace(/[\r\n]/g, '')) + ) + .getDataAsString() + + if ( + rawContent.indexOf(entryMd.trim()) !== -1 || + (commitMessage && rawContent.indexOf(commitMessage) !== -1) + ) { + console.log( + '[gitHubSync] Idempotent Skip: Entry already exists in', + filePath + ) + return 'IDEMPOTENT_SKIP' + } + } else { + console.error( + '[gitHubSync] Error fetching file from GitHub (HTTP ' + + statusCode + + '):', + res.getContentText() + ) + return false + } + + const updatedContent = insertEntryIntoLogSection(rawContent, entryMd) + + assertClean_(entryMd, 'new entry for ' + filePath) + + const base64Updated = utils.base64Encode( + utils.newBlob(updatedContent).getBytes() + ) + + // Validate the ACTUAL Base64 round trip at the payload boundary: decode what + // we are about to PUT and confirm no non-ASCII character was flattened to + // '?'. Comparing updatedContent to entryMd/rawContent directly is inert + // because updatedContent contains both source strings unchanged. + const renderedContent = utils + .newBlob(utils.base64Decode(base64Updated)) + .getDataAsString() + assertNoAsciiReplacement_(updatedContent, renderedContent) + + const putPayload = { + message: + commitMessage || + 'feat(ingestion): append ingested document entry via Google Apps Script', + content: base64Updated, + branch: 'main', + } + if (sha) { + putPayload.sha = sha + } + + const putOptions = { + method: 'put', + headers: headers, + contentType: 'application/json', + payload: JSON.stringify(putPayload), + muteHttpExceptions: true, + } + + const putRes = urlFetchApp.fetch(url, putOptions) + const putStatus = putRes.getResponseCode() + + if (putStatus === 200 || putStatus === 201) { + console.log( + '[gitHubSync] Successfully committed markdown entry to GitHub:', + filePath + ) + return true + } else if (putStatus === 409) { + console.warn('[gitHubSync] SHA collision (HTTP 409) on file:', filePath) + return false + } else { + console.error( + '[gitHubSync] Error committing to GitHub (HTTP ' + putStatus + '):', + putRes.getContentText() + ) + return false + } + } catch (e) { + console.error('[gitHubSync] Exception calling GitHub API:', e.message) + return false + } +} + +function formatAuditDate_(d) { + const y = d.getFullYear() + const m = ('0' + (d.getMonth() + 1)).slice(-2) + const da = ('0' + d.getDate()).slice(-2) + return y + '/' + m + '/' + da +} + +function getSearchDateRange_(dateStr, days) { + const parts = dateStr.split('-') + const year = Number.parseInt(parts[0], 10) + const month = Number.parseInt(parts[1], 10) - 1 + const day = Number.parseInt(parts[2], 10) + const dt = new Date(year, month, day) + + const beforeDt = new Date(dt.getTime() + (days + 1) * 86400000) + const afterDt = new Date(dt.getTime() - days * 86400000) + + return { + after: formatAuditDate_(afterDt), + before: formatAuditDate_(beforeDt), + } +} + +/** + * Checks whether a sender matches known non-canonical newsletter or social domains. + * + * @param {string} sender - Raw sender header + * @returns {boolean} True if sender is a non-canonical newsletter/social source + */ +function isNonCanonicalSender_(sender) { + return ( + matchesSenderDomain_(sender, [ + 'substack.com', + 'redditmail.com', + 'nextdoor.com', + 'facebookmail.com', + 'instagram.com', + 'twitter.com', + 'x.com', + 'pinterest.com', + 'quora.com', + ]) || /\bnewsletters?@/i.test(String(sender || '')) + ) +} + +/** + * Checks whether a thread has been assigned a secondary Gmail category tab + * (Updates, Promotions, Social, Forums) via user labels, thread properties, + * or the Advanced Gmail API (`CATEGORY_*` system label IDs). + * + * @param {Object} thread - GmailThread or mock object + * @param {string[]} rawLabels - User label names on the thread + * @param {Object} [services] - Optional injected services ({ Gmail }) + * @returns {boolean} True if thread is in a secondary category stream + */ +function hasSecondaryCategory_(thread, rawLabels, services) { + if ( + Array.isArray(rawLabels) && + rawLabels.some(function (l) { + return /promotions|updates|social|forums|archive/i.test(String(l || '')) + }) + ) { + return true + } + + const threadCat = + typeof thread?.getCategory === 'function' + ? thread.getCategory() + : thread?.category || '' + if ( + threadCat && + /promotions|updates|social|forums|CATEGORY_(UPDATES|PROMOTIONS|SOCIAL|FORUMS)/i.test( + String(threadCat) + ) + ) { + return true + } + + const gmailAdv = + services?.Gmail || (typeof Gmail !== 'undefined' ? Gmail : null) + const threadId = + typeof thread?.getId === 'function' ? thread.getId() : thread?.id || '' + if (threadId && typeof gmailAdv?.Users?.Threads?.get === 'function') { + try { + const threadMeta = gmailAdv.Users.Threads.get('me', threadId) + const labelIds = [].concat( + Array.isArray(threadMeta?.labelIds) ? threadMeta.labelIds : [] + ) + if (Array.isArray(threadMeta?.messages)) { + for (let m = 0; m < threadMeta.messages.length; m++) { + if (Array.isArray(threadMeta.messages[m]?.labelIds)) { + labelIds.push(...threadMeta.messages[m].labelIds) + } + } + } + if ( + labelIds.some(function (id) { + return /^CATEGORY_(UPDATES|PROMOTIONS|SOCIAL|FORUMS)$/i.test( + String(id || '') + ) + }) + ) { + return true + } + } catch { + // Non-blocking fallback when Advanced Gmail API is unavailable + } + } + + return false +} + +/** + * Audits a collection of processed Gmail threads for classification anomalies. + * + * @param {Array} threads - Array of GmailThread-like objects + * @param {Object} config - Classifier config + * @param {Object} [services] - Optional injected services ({ Gmail }) + * @returns {Object} report - { scannedCount, flaggedCount, findings, summary } + */ +function auditClassifications(threads, config, services) { + const canonicalDomains = config?.canonicalDomains || [ + '01_Household', + '02_Finance_Legal', + '03_Vehicles', + '04_Family_Health', + '05_Tech_Infrastructure', + '06_Work_Career', + '07_Community_NonProfit', + ] + + const PROMO_KEYWORDS = + /\b(sale|\d+% off|deal of the day|clearance|limited time offer|coupon|shop now)\b/i + const NEWSLETTER_KEYWORDS = + /\b(weekly digest|daily digest|newsletter|roundup|top stories)\b/i + const ORDER_KEYWORDS = + /\b(order confirmation|your order|receipt|payment received|invoice|shipped)\b/i + const TOS_KEYWORDS = + /\b(terms of service|privacy policy|terms and conditions|user agreement|arbitration terms)\b/i + const SURVEY_KEYWORDS = + /\b(survey|feedback|net promoter|nps|rate your experience|how did we do|review us)\b/i + const POLITICAL_KEYWORDS = + /\b(campaign|pac|fundraiser|election|candidate|contribute now|donate to our campaign)\b/i + + const findings = [] + + if (!Array.isArray(threads)) { + return { + scannedCount: 0, + flaggedCount: 0, + findings: [], + summary: 'No threads to audit.', + } + } + + threads.forEach((thread) => { + if (!thread) return + const id = + typeof thread.getId === 'function' ? thread.getId() : thread.id || '' + const subject = + typeof thread.getFirstMessageSubject === 'function' + ? thread.getFirstMessageSubject() + : thread.subject || '' + + let sender = '' + if (typeof thread.getMessages === 'function') { + const msgs = thread.getMessages() + if (msgs && msgs.length > 0 && typeof msgs[0].getFrom === 'function') { + sender = msgs[0].getFrom() + } + } else if (thread.sender) { + sender = thread.sender + } + + let rawLabels = [] + if (typeof thread.getLabels === 'function') { + const labelObjs = thread.getLabels() || [] + rawLabels = labelObjs.map((l) => + typeof l.getName === 'function' ? l.getName() : String(l) + ) + } else if (Array.isArray(thread.labels)) { + rawLabels = thread.labels + } + + const assignedCanonical = [] + rawLabels.forEach((l) => { + if (!l || typeof l !== 'string') return + const resolved = resolveCanonicalDomainFromLabels([l], canonicalDomains) + if (resolved && canonicalDomains.includes(resolved)) { + if (!assignedCanonical.includes(resolved)) { + assignedCanonical.push(resolved) + } + return + } + const match = l.match(/^(0[1-7]_[A-Za-z_]+)/) + if (match && canonicalDomains.includes(match[1])) { + if (!assignedCanonical.includes(match[1])) { + assignedCanonical.push(match[1]) + } + } + }) + + let isInTrash = false + if (typeof thread.isInTrash === 'function') { + try { + isInTrash = thread.isInTrash() + } catch {} + } else if (thread.isInTrash === true || thread.isTrash === true) { + isInTrash = true + } + + const flags = [] + const fullText = (subject + ' ' + sender).toLowerCase() + const isReceiptOrOrder = ORDER_KEYWORDS.test(fullText) + const isNonCanonicalStream = + isInTrash || + (!isReceiptOrOrder && + (rawLabels.some((l) => + /promotions|social|forums|marketing|promo|deal|newsletter/i.test(l) + ) || + isNonCanonicalSender_(sender) || + PROMO_KEYWORDS.test(subject) || + NEWSLETTER_KEYWORDS.test(subject) || + TOS_KEYWORDS.test(subject) || + SURVEY_KEYWORDS.test(subject) || + POLITICAL_KEYWORDS.test(subject))) + + // 1. Missing Domain + if (assignedCanonical.length === 0 && !isNonCanonicalStream) { + flags.push( + 'MISSING_CANONICAL_DOMAIN: Thread marked as processed has no canonical domain label.' + ) + } + + // 2. Conflicting Domains + if (assignedCanonical.length > 1) { + flags.push( + 'CONFLICTING_DOMAINS: Multiple canonical domain labels assigned: ' + + assignedCanonical.join(', ') + ) + } + + // 3. Heuristic Checks + if (ORDER_KEYWORDS.test(fullText)) { + const hasFinanceOrHousehold = assignedCanonical.some( + (l) => + l.includes('02_Finance_Legal') || + l.includes('01_Household') || + l.includes('03_Vehicles') + ) + if (assignedCanonical.length > 0 && !hasFinanceOrHousehold) { + flags.push( + 'SUSPICIOUS_ROUTING: Purchase/receipt keywords detected but domain is ' + + assignedCanonical.join(', ') + + ' instead of 02_Finance_Legal.' + ) + } + } + + if (PROMO_KEYWORDS.test(subject) && assignedCanonical.length > 0) { + const hasMarketingSublabel = rawLabels.some((l) => + /promo|marketing|deal|coupon/i.test(l) + ) + if (!hasMarketingSublabel) { + flags.push( + 'PROMOTIONAL_CONTENT: Promotional sale keywords in subject tagged under core domain ' + + assignedCanonical.join(', ') + + ' without marketing sub-label.' + ) + } + } + + if (NEWSLETTER_KEYWORDS.test(subject) && assignedCanonical.length > 0) { + const hasNewsletterSublabel = rawLabels.some((l) => + /newsletter|digest|news/i.test(l) + ) + if (!hasNewsletterSublabel) { + flags.push( + 'UNLABELED_NEWSLETTER: Generic newsletter/digest subject tagged under core domain ' + + assignedCanonical.join(', ') + + ' without newsletter sub-label.' + ) + } + } + + if ( + TOS_KEYWORDS.test(subject) && + assignedCanonical.some((l) => l.includes('02_Finance_Legal')) + ) { + flags.push( + 'COMMERCIAL_TOS_IN_LEGAL: Routine commercial terms of service/privacy policy update tagged under 02_Finance_Legal instead of non-canonical broadcast.' + ) + } + + // 4. Drive Attachment Persistence & Tagging Check + const driveApp = + config?.driveApp || (typeof DriveApp !== 'undefined' ? DriveApp : null) + if ( + driveApp && + assignedCanonical.length === 1 && + typeof thread.getMessages === 'function' + ) { + const domain = assignedCanonical[0] + const subLabel = + rawLabels.find((l) => { + if ( + !l || + typeof l !== 'string' || + !l.includes('/') || + l.toLowerCase().startsWith('ai/') || + l.startsWith('Retention/') || + l.includes('Archives') + ) { + return false + } + if (/^0[1-7]_/.test(l)) { + return l.startsWith(domain + '/') + } + return true + }) || '' + const subfolderName = resolveTaxonomySubfolderName(domain, subLabel) + const msgs = thread.getMessages() || [] + msgs.forEach((msg) => { + const atts = getMessageAttachments_(msg) + atts.forEach((att) => { + if (isEligibleAttachment(att)) { + const attName = + typeof att.getName === 'function' + ? att.getName() + : att.name || 'unnamed' + try { + const targetFolder = findDriveTaxonomyFolder_( + domain, + subfolderName, + driveApp, + config?.driveRootFolderName + ) + const existingFiles = targetFolder + ? targetFolder.getFilesByName(attName) + : null + if ( + !existingFiles || + (typeof existingFiles.hasNext === 'function' && + !existingFiles.hasNext()) + ) { + flags.push( + 'MISSING_DRIVE_ATTACHMENT: Eligible attachment "' + + attName + + '" is missing from Drive folder "' + + domain + + '/' + + subfolderName + + '".' + ) + } else if (typeof existingFiles.next === 'function') { + const driveFile = existingFiles.next() + const desc = + typeof driveFile.getDescription === 'function' + ? driveFile.getDescription() + : '' + if (!desc || !desc.includes('[AI_INDEXED]')) { + flags.push( + 'UNTAGGED_DRIVE_ATTACHMENT: Attachment "' + + attName + + '" exists in Drive but lacks [AI_INDEXED] metadata description.' + ) + } + } + } catch { + // Non-blocking folder check + } + } + }) + }) + } + + // 5. Archived from Inbox Without Action Check + const isArchived = + (typeof thread.isInInbox === 'function' && + thread.isInInbox() === false) || + thread.isInInbox === false + if (isArchived && assignedCanonical.length > 0) { + const isStarred = + (typeof thread.isStarred === 'function' && + thread.isStarred() === true) || + thread.isStarred === true + const userEmail = (config && config.userAccountEmail) || '' + const isSentOrReplied = + typeof isThreadSentOrRepliedByUser === 'function' + ? isThreadSentOrRepliedByUser(thread, userEmail) + : false + const hasSecondaryCategory = hasSecondaryCategory_( + thread, + rawLabels, + services + ) + + if (!isStarred && !isSentOrReplied && !hasSecondaryCategory) { + flags.push( + 'ARCHIVED_WITHOUT_ACTION: Thread tagged under ' + + assignedCanonical.join(', ') + + ' was archived from Inbox without user reply or star; candidate for secondary stream routing (Updates).' + ) + } + } + + if (flags.length > 0) { + findings.push({ + threadId: id, + subject: subject, + sender: sender, + canonicalLabels: assignedCanonical, + allLabels: rawLabels, + flags: flags, + }) + } + }) + + return { + scannedCount: threads.length, + flaggedCount: findings.length, + findings: findings, + summary: + 'Audited ' + + threads.length + + ' thread(s); ' + + findings.length + + ' anomaly flag(s) identified.', + } +} + +/** + * Formats an audit report into a human-readable email digest or log message. + * + * @param {Object} report - Result from auditClassifications + * @returns {string} Formatted digest text + */ +function formatAuditDigest(report) { + if (!report || report.flaggedCount === 0) { + return ( + 'Gmail AI Classifier Audit: All ' + + (report ? report.scannedCount : 0) + + ' analyzed threads are compliant. No anomalies detected.' + ) + } + + let text = '===================================================\n' + text += ' GMAIL AI CLASSIFICATION AUDIT REPORT\n' + text += '===================================================\n' + text += report.summary + '\n\n' + + report.findings.forEach((finding, idx) => { + if (!finding) return + const labelsStr = + Array.isArray(finding.canonicalLabels) && + finding.canonicalLabels.length > 0 + ? finding.canonicalLabels.join(', ') + : '(none)' + const flagsList = Array.isArray(finding.flags) ? finding.flags : [] + + text += + idx + 1 + '. Subject: "' + (finding.subject || '(no subject)') + '"\n' + text += ' Sender: ' + (finding.sender || '(unknown)') + '\n' + text += ' Labels: ' + labelsStr + '\n' + text += ' Flags:\n' + flagsList.forEach((f) => { + text += ' • ' + f + '\n' + }) + text += '\n' + }) + + text += '---------------------------------------------------\n' + text += + 'Tuning Action: Update ScriptProperty CUSTOM_PROMPT_RULES to add calibrated sender rules.\n' + text += '===================================================\n' + return text +} + +/** + * Daily audit sweep that examines recently classified threads (default: last 2 days), + * identifies classification anomalies, logs them, and sends an email digest. + * + * @param {Object} [customConfig] - Optional override configuration + * @param {Object} [customServices] - Optional injected services (GmailApp) + * @returns {Object} report - { scannedCount, flaggedCount, findings, summary } + */ +function auditEmailClassifications(customConfig, customServices) { + console.log( + '[auditEmailClassifications] Starting daily classification audit sweep...' + ) + const services = customServices || {} + const config = resolveEffectiveConfig_(customConfig) + const gmail = + services.GmailApp || (typeof GmailApp !== 'undefined' ? GmailApp : null) + const lookback = config.auditLookbackDays || 2 + const query = + 'label:' + + (config.processedLabel || 'Processed') + + ' newer_than:' + + lookback + + 'd' + + const rawThreads = + gmail && typeof gmail.search === 'function' + ? gmail.search(query, 0, 50) + : [] + const threads = sortThreadsByLastMessageDate_(rawThreads) + console.log( + '[auditEmailClassifications] Found ' + + threads.length + + ' processed thread(s) in last ' + + lookback + + ' day(s).' + ) + + const report = auditClassifications(threads, config, services) + console.log('[auditEmailClassifications] ' + report.summary) + + if (report.flaggedCount > 0) { + const digest = formatAuditDigest(report) + console.warn('[auditEmailClassifications] Anomalies detected:\n' + digest) + + if ( + config.auditDigestEmail && + gmail && + typeof gmail.sendEmail === 'function' + ) { + try { + gmail.sendEmail( + config.auditDigestEmail, + '[Gmail AI Classifier] Daily Classification Audit: ' + + report.flaggedCount + + ' anomalies detected', + digest + ) + console.log( + '[auditEmailClassifications] Audit digest email sent to ' + + config.auditDigestEmail + ) + } catch (e) { + console.error( + '[auditEmailClassifications] Failed to send audit digest email:', + e.message + ) + } + } + } else { + console.log( + '[auditEmailClassifications] All analyzed threads are compliant with taxonomy rules.' + ) + } + + return report +} + +/** + * Sets up a weekly cloud trigger to run purgeExpiredEmailsByRetentionPolicy every Sunday at 1:00 AM. + */ +function setupWeeklyRetentionTrigger(services) { + const scriptApp = + services?.ScriptApp || (typeof ScriptApp !== 'undefined' ? ScriptApp : null) + if (!scriptApp) return + const triggers = scriptApp.getProjectTriggers() || [] + for (let i = 0; i < triggers.length; i++) { + if ( + triggers[i].getHandlerFunction() === 'purgeExpiredEmailsByRetentionPolicy' + ) { + scriptApp.deleteTrigger(triggers[i]) + } + } + + scriptApp + .newTrigger('purgeExpiredEmailsByRetentionPolicy') + .timeBased() + .onWeekDay(scriptApp.WeekDay ? scriptApp.WeekDay.SUNDAY : 1) + .atHour(1) + .create() + + console.log( + '[setupWeeklyRetentionTrigger] Established weekly Sunday 1:00 AM retention cleanup trigger.' + ) +} + +/** + * Sets up a daily cloud trigger to run auditEmailClassifications every morning at 6:00 AM. + */ +function setupDailyAuditTrigger(services) { + const scriptApp = + services?.ScriptApp || (typeof ScriptApp !== 'undefined' ? ScriptApp : null) + if (!scriptApp) return + const triggers = scriptApp.getProjectTriggers() || [] + for (let i = 0; i < triggers.length; i++) { + if (triggers[i].getHandlerFunction() === 'auditEmailClassifications') { + scriptApp.deleteTrigger(triggers[i]) + } + } + + scriptApp + .newTrigger('auditEmailClassifications') + .timeBased() + .everyDays(1) + .atHour(6) + .create() + + console.log( + '[setupDailyAuditTrigger] Established daily 6:00 AM classification audit trigger.' + ) +} + +/** + * Sets up a recurring cloud trigger to run auditEmailClassifications every 4 hours. + */ +function setupFourHourAuditTrigger(services) { + const scriptApp = + services?.ScriptApp || (typeof ScriptApp !== 'undefined' ? ScriptApp : null) + if (!scriptApp) return + const triggers = scriptApp.getProjectTriggers() || [] + for (let i = 0; i < triggers.length; i++) { + if (triggers[i].getHandlerFunction() === 'auditEmailClassifications') { + scriptApp.deleteTrigger(triggers[i]) + } + } + + scriptApp + .newTrigger('auditEmailClassifications') + .timeBased() + .everyHours(4) + .create() + + console.log( + '[setupFourHourAuditTrigger] Established 4-hour classification audit trigger.' + ) +} + +/** + * Clears all active time-driven triggers for this script. + */ +function stopAllTriggers(services) { + const scriptApp = + services?.ScriptApp || (typeof ScriptApp !== 'undefined' ? ScriptApp : null) + if (!scriptApp) return + const triggers = scriptApp.getProjectTriggers() || [] + for (let i = 0; i < triggers.length; i++) { + scriptApp.deleteTrigger(triggers[i]) + } + console.log('[stopAllTriggers] All script triggers removed.') +} + +/** + * Creates an automatic Cloud Trigger that runs email classification every 5 minutes 24/7. + */ +function setupFiveMinuteTrigger(services) { + const scriptApp = + services?.ScriptApp || (typeof ScriptApp !== 'undefined' ? ScriptApp : null) + if (!scriptApp) return + stopAllTriggers(services) + scriptApp + .newTrigger('processEmailsWithAiClassifier') + .timeBased() + .everyMinutes(5) + .create() + setupWeeklyRetentionTrigger(services) + setupDailyAuditTrigger(services) + console.log( + '[setupFiveMinuteTrigger] Successfully established 5-minute recurring cloud trigger, weekly retention trigger, and daily audit trigger.' + ) +} + +/** + * Decommissioned static filter creation helper (retained as no-op for backward compatibility). + */ +function createGmailFilterRule(_senderEmail, _targetLabelName) { + console.log( + '[createGmailFilterRule] Permanent filter auto-creation is permanently decommissioned. No-op.' + ) +} + +// --------------------------------------------------------------------------- +// Attachment Persistence to Google Drive along Taxonomy Path +// --------------------------------------------------------------------------- + +const CANONICAL_TAXONOMY_SUBFOLDERS = { + '01_Household': [ + 'Primary_House', + 'Shop_Build', + 'Rental_Property', + 'Archive_Property', + 'Bills', + 'Maintenance', + ], + '02_Finance_Legal': [ + 'Taxes', + 'Insurance', + 'Banking', + 'Legal_Court', + 'Estate_Planning', + 'Bills', + 'Purchases', + ], + '03_Vehicles': ['Car_Hunt', 'Vehicle_Fleet', 'Maintenance'], + '04_Family_Health': [ + 'Family_General', + 'Students', + 'Adults', + 'Medical_Records', + 'Activities_Camps', + ], + '05_Tech_Infrastructure': ['NAS_Backups', 'Tasker', 'Hardware_Licenses'], + '06_Work_Career': [ + 'Career_Interviews', + 'Career_DJ', + 'Career_Rachel', + 'Employer', + 'Expenses_Admin', + ], + '07_Community_NonProfit': ['Community_BOD', 'Projects_Telemetry'], +} + +const DEFAULT_DOMAIN_SUBLABEL_MAP = { + '01_Household': 'Household/General', + '02_Finance_Legal': 'Finance/Banking', + '03_Vehicles': 'Vehicles/General', + '04_Family_Health': 'Family/General', + '05_Tech_Infrastructure': 'Tech/Infrastructure', + '06_Work_Career': 'Work/General', + '07_Community_NonProfit': 'Community/NonProfit', +} + +function isRootDomainLabel(name) { + if (!name || typeof name !== 'string') return false + return ( + !name.includes('/') && + (/^0[1-7]_/.test(name) || + (typeof DEFAULT_DOMAIN_SUBLABEL_MAP !== 'undefined' && + Object.prototype.hasOwnProperty.call( + DEFAULT_DOMAIN_SUBLABEL_MAP, + name + ))) + ) +} + +const SUBLABEL_TO_FOLDER_MAP = { + // 01_Household + 'household/general': 'Primary_House', + 'household/property': 'Primary_House', + 'household/primary-property': 'Primary_House', + 'household/primary_house': 'Primary_House', + 'household/primary': 'Primary_House', + 'household/home-maintenance': 'Maintenance', + 'household/maintenance': 'Maintenance', + 'household/utilities': 'Bills', + 'household/bills': 'Bills', + 'household/shop': 'Shop_Build', + 'household/shop-build': 'Shop_Build', + 'household/travel': 'Primary_House', + + // 02_Finance_Legal + 'finance/banking': 'Banking', + 'finance/bills': 'Bills', + 'finance/taxes': 'Taxes', + 'finance/insurance': 'Insurance', + 'finance/purchases': 'Purchases', + 'finance/legal': 'Legal_Court', + 'finance/legal_court': 'Legal_Court', + 'finance/donations': 'Taxes', + 'finance/charitable-donations': 'Taxes', + 'finance/estate-planning': 'Estate_Planning', + 'finance/estate_planning': 'Estate_Planning', + + // 03_Vehicles + 'vehicles/general': 'Maintenance', + 'vehicles/maintenance': 'Maintenance', + 'vehicles/parts-orders': 'Maintenance', + 'vehicles/insurance': 'Insurance', + 'vehicles/registration': 'Maintenance', + 'vehicles/rental-cars': 'Maintenance', + 'vehicles/car-hunt': 'Car_Hunt', + 'vehicles/car_hunt': 'Car_Hunt', + + // 04_Family_Health + 'family/general': 'Family_General', + 'family/correspondence': 'Family_General', + 'family/personal-correspondence': 'Family_General', + 'family/legal': 'Family_General', + 'family/medical': 'Medical_Records', + 'family/medical-records': 'Medical_Records', + 'family/medical-student': 'Medical_Records', + 'family/health-general': 'Medical_Records', + 'family/school-student': 'Students', + 'family/school-charley': 'Students', + 'family/school-toby': 'Students', + 'family/school-tide': 'Students', + 'family/parents': 'Family_General', + 'family/kids/tide': 'Students', + 'family/kids/toby': 'Students', + 'family/kids/david': 'Students', + 'family/kids': 'Students', + 'family/kids/school-tide': 'Students', + 'family/kids/school-toby': 'Students', + 'family/kids/school': 'Students', + 'family/kids/tide-health': 'Medical_Records', + 'family/kids/toby-health': 'Medical_Records', + 'family/activities': 'Activities_Camps', + 'family/camps': 'Activities_Camps', + 'family/sisters': 'Family_General', + + // 05_Tech_Infrastructure + 'tech/infrastructure': 'Hardware_Licenses', + 'tech/cloud': 'NAS_Backups', + 'tech/security': 'Hardware_Licenses', + 'tech/alerts': 'NAS_Backups', + 'tech/alerts-monitoring': 'NAS_Backups', + 'tech/backups': 'NAS_Backups', + 'tech/hardware': 'Hardware_Licenses', + 'tech/hardware-licenses': 'Hardware_Licenses', + 'tech/cloud-gcp': 'NAS_Backups', + + // 06_Work_Career + 'work/general': 'Career_Interviews', + 'work/career': 'Career_Interviews', + 'work/employer': 'Career_Interviews', + 'career/dj': 'Career_DJ', + 'career/rachel': 'Career_Rachel', + 'career/employer': 'Employer', + 'work/notes': 'Career_Interviews', + 'work/expenses': 'Expenses_Admin', + 'work/expenses-admin': 'Expenses_Admin', + 'work/architecture': 'Career_Interviews', + + // 07_Community_NonProfit + 'community/nonprofit': 'Community_BOD', + 'nonprofit/general': 'Community_BOD', + 'projects/charity': 'Community_BOD', + 'community/bod': 'Community_BOD', + 'projects/telemetry': 'Projects_Telemetry', + 'projects/beekeeping': 'Projects_Telemetry', + 'projects/hog': 'Community_BOD', + 'community/nonprofit-bod': 'Community_BOD', + 'community/charity': 'Community_BOD', +} + +/** + * Validates whether an email classification represents a canonical domain. + * Non-canonical emails (promotions, newsletters, spam) return null/empty for canonicalDomain. + * + * @param {Object} classification - Gemini classification object + * @param {Object} [config] - Classifier configuration + * @returns {boolean} True if canonical domain, false if non-canonical or null + */ +function isCanonicalClassification(classification, config) { + if (!classification || typeof classification !== 'object') return false + const domain = + classification.canonicalDomain || classification.canonical_label + if (!domain || typeof domain !== 'string') return false + const trimmed = domain.trim() + if (trimmed === '' || trimmed === 'null' || trimmed === 'undefined') + return false + + const allowedDomains = config?.canonicalDomains || [ + '01_Household', + '02_Finance_Legal', + '03_Vehicles', + '04_Family_Health', + '05_Tech_Infrastructure', + '06_Work_Career', + '07_Community_NonProfit', + ] + + return allowedDomains.some( + (d) => trimmed === d || trimmed.startsWith(d + '/') + ) +} + +/** + * Resolves the 2nd-level Google Drive taxonomy subfolder name given a canonical domain and sub-label. + * + * @param {string} canonicalDomain - e.g. "02_Finance_Legal" + * @param {string} [subLabel] - e.g. "Finance/Banking" or "Bills" + * @returns {string} Subfolder name + */ +function resolveTaxonomySubfolderName(canonicalDomain, subLabel) { + if (subLabel && typeof subLabel === 'string') { + const normalized = subLabel.trim().toLowerCase() + if (SUBLABEL_TO_FOLDER_MAP[normalized]) { + return SUBLABEL_TO_FOLDER_MAP[normalized] + } + + if ( + normalized.startsWith('family/kids') || + normalized.startsWith('family/school') + ) { + if (normalized.includes('health') || normalized.includes('medical')) { + return 'Medical_Records' + } + return 'Students' + } + if ( + normalized.startsWith('family/sisters') || + normalized.startsWith('family/parents') + ) { + return 'Family_General' + } + + const parts = subLabel.split('/') + const subPart = (parts.length > 1 ? parts[1] : parts[0]).trim() + const sanitized = subPart.replace(/[-\s]+/g, '_') + + const knownSubfolders = CANONICAL_TAXONOMY_SUBFOLDERS[canonicalDomain] || [] + const match = knownSubfolders.find( + (sf) => sf.toLowerCase() === sanitized.toLowerCase() + ) + if (match) return match + + if (sanitized.length > 0) return sanitized + } + + const defaults = { + '01_Household': 'Primary_House', + '02_Finance_Legal': 'Banking', + '03_Vehicles': 'Maintenance', + '04_Family_Health': 'Medical_Records', + '05_Tech_Infrastructure': 'NAS_Backups', + '06_Work_Career': 'Career_Interviews', + '07_Community_NonProfit': 'Community_BOD', + } + return defaults[canonicalDomain] || 'General' +} + +/** + * Evaluates whether an email attachment is an eligible canonical document + * and filters out inline images, email signatures, tracking pixels, and non-document artifacts. + * + * @param {Object} att - Attachment blob or mock object + * @returns {{ eligible: boolean, reason: string }} + */ +function evaluateAttachmentEligibility(att) { + if (!att) return { eligible: false, reason: 'NULL_OR_EMPTY' } + const name = + typeof att.getName === 'function' ? att.getName() : att.name || '' + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return { eligible: false, reason: 'MISSING_NAME' } + } + + let size = 0 + if (typeof att.getSize === 'function') { + size = att.getSize() + } else if (typeof att.getBytes === 'function') { + const bytes = att.getBytes() + size = bytes ? bytes.length : 0 + } else if (att.bytes) { + size = att.bytes.length + } else if (att.size !== undefined) { + size = att.size + } + + // 1. Skip empty files or files exceeding GAS payload limit (30MB limit; cap at 25MB) + if (size <= 0) { + return { eligible: false, reason: 'ZERO_BYTE' } + } + if (size > 25 * 1024 * 1024) { + return { eligible: false, reason: 'EXCEEDS_GAS_SIZE_LIMIT (>25MB)' } + } + + const cleanName = name.trim() + const dotIndex = cleanName.lastIndexOf('.') + const ext = dotIndex > 0 ? cleanName.slice(dotIndex + 1).toLowerCase() : '' + const stem = ( + dotIndex > 0 ? cleanName.slice(0, dotIndex) : cleanName + ).toLowerCase() + const mimeType = ( + typeof att.getContentType === 'function' + ? att.getContentType() + : att.contentType || '' + ).toLowerCase() + + // 2. Unconditionally blocked non-document extensions + const BLOCKED_EXTENSIONS = new Set([ + 'ics', + 'ical', + 'ifb', + 'vcf', + 'vcard', + 'html', + 'htm', + 'css', + 'js', + 'mjs', + 'json', + 'xml', + 'rss', + 'p7s', + 'p7m', + 'p7c', + 'asc', + 'sig', + 'dat', + 'eml', + 'msg', + 'exe', + 'dmg', + 'pkg', + 'bin', + 'apk', + 'app', + 'sh', + 'bat', + 'cmd', + 'msi', + 'ttf', + 'woff', + 'woff2', + 'eot', + 'otf', + 'ico', + 'gif', + ]) + + if (BLOCKED_EXTENSIONS.has(ext)) { + return { eligible: false, reason: 'BLOCKED_EXTENSION:.' + ext } + } + + // 4. Canonical document types (Whitelisted) + const DOCUMENT_EXTENSIONS = new Set([ + 'pdf', + 'docx', + 'doc', + 'rtf', + 'odt', + 'pages', + 'xlsx', + 'xls', + 'csv', + 'tsv', + 'ods', + 'numbers', + 'pptx', + 'ppt', + 'key', + ]) + + if (DOCUMENT_EXTENSIONS.has(ext)) { + return { eligible: true, reason: 'CANONICAL_DOCUMENT' } + } + + // 5. Plain text files (.txt) - must be substantial and not boilerplate disclaimer + if (ext === 'txt') { + if (/^(disclaimer|signature|notice|footer|legal|terms)$/i.test(stem)) { + return { eligible: false, reason: 'TEXT_BOILERPLATE_DISCLAIMER' } + } + return { eligible: true, reason: 'CANONICAL_TEXT_DOCUMENT' } + } + + // 6. Archives (.zip) + if (ext === 'zip') { + return { eligible: true, reason: 'CANONICAL_ARCHIVE' } + } + + // 7. Image files (.jpg, .jpeg, .png, .heic, .tiff, .tif, .webp) + const IMAGE_EXTENSIONS = new Set([ + 'jpg', + 'jpeg', + 'png', + 'heic', + 'tiff', + 'tif', + 'webp', + ]) + + const isImage = IMAGE_EXTENSIONS.has(ext) || mimeType.startsWith('image/') + + if (isImage) { + // 7a. Stricter size threshold: genuine receipt/document photos are virtually always >= 35KB + if (size < 35 * 1024) { + return { eligible: false, reason: 'IMAGE_BELOW_SIZE_THRESHOLD (<35KB)' } + } + + // 7b. Tracking, logo, signature stem blacklist + const SIGNATURE_STEM_PATTERNS = [ + /logo/i, + /(^|[-_.\s])(sig|signature)($|[-_.\s\d])/i, + /banner/i, + /header/i, + /footer/i, + /avatar/i, + /badge/i, + /icon/i, + /button/i, + /social/i, + /facebook/i, + /twitter/i, + /instagram/i, + /linkedin/i, + /youtube/i, + /tiktok/i, + /pinterest/i, + /whatsapp/i, + /spacer/i, + /pixel/i, + /tracking/i, + /divider/i, + /border/i, + /bullet/i, + /rating/i, + /star/i, + /thumbnail/i, + /thumb/i, + /outlook-[a-z0-9]+/i, + /^image\d*$/i, + /^unnamed/i, + /^pasted/i, + /^photo$/i, + /^img$/i, + /^attachment$/i, + ] + + for (let i = 0; i < SIGNATURE_STEM_PATTERNS.length; i++) { + if (SIGNATURE_STEM_PATTERNS[i].test(stem)) { + return { + eligible: false, + reason: + 'SIGNATURE_OR_LOGO_PATTERN:' + SIGNATURE_STEM_PATTERNS[i].source, + } + } + } + + return { eligible: true, reason: 'CANONICAL_IMAGE_SCAN' } + } + + // Unknown or unsupported extension + return { eligible: false, reason: 'UNKNOWN_OR_UNSUPPORTED_EXTENSION:.' + ext } +} + +/** + * Backward-compatible boolean evaluator. + * + * @param {Object} att - Attachment blob or mock object + * @returns {boolean} True if eligible canonical document + */ +function isEligibleAttachment(att) { + return evaluateAttachmentEligibility(att).eligible +} + +/** + * Retrieves attachments from a Gmail message, excluding inline images by default. + * + * @param {Object} msg - GmailMessage or mock + * @returns {Array} Array of attachments + */ +function getMessageAttachments_(msg) { + if (!msg) return [] + if (typeof msg.getAttachments === 'function') { + try { + return ( + msg.getAttachments({ + includeInlineImages: false, + includeAttachments: true, + }) || [] + ) + } catch { + return msg.getAttachments() || [] + } + } + return msg.attachments || [] +} + +/** + * Enriches email snippet text with eligible attachment filenames so classifiers + * (deterministic rules and Gemini) have full document context. + * + * @param {string} snippet - Raw or truncated body snippet + * @param {Object} message - GmailMessage or mock + * @returns {string} Enriched snippet string + */ +function enrichSnippetWithAttachments_(snippet, message) { + var baseSnippet = (snippet || '').trim() + if (!message || typeof message.getAttachments !== 'function') { + return baseSnippet + } + if (baseSnippet.indexOf('[Attached:') !== -1) { + return baseSnippet + } + try { + var atts = message.getAttachments() || [] + var attNames = atts + .map(function (a) { + return typeof a.getName === 'function' ? a.getName() : a.name || '' + }) + .filter(function (name) { + if (!name || typeof name !== 'string') return false + var clean = name.trim() + var dot = clean.lastIndexOf('.') + var ext = dot > 0 ? clean.slice(dot + 1).toLowerCase() : '' + var stem = + dot > 0 ? clean.slice(0, dot).toLowerCase() : clean.toLowerCase() + if ( + /(logo|banner|header|footer|avatar|badge|icon|button|spacer|pixel|tracking|divider|rating|star|thumb|unnamed|outlook-)/i.test( + stem + ) || + /(^|[-_.])sig(nature)?([-_.]|$)/i.test(stem) || + /^image\d*$/i.test(stem) + ) { + return false + } + if ( + /^(ics|ical|vcf|vcard|htm|html|css|js|json|xml|dat|p7s|p7m|eml|msg)$/i.test( + ext + ) + ) { + return false + } + return true + }) + if (attNames.length > 0) { + return ( + (baseSnippet ? baseSnippet + ' ' : '') + + '[Attached: ' + + attNames.join(', ') + + ']' + ) + } + } catch { + // ignore attachment inspection errors + } + return baseSnippet +} + +function getExistingChildFolder_(parentFolder, folderName) { + const folders = + parentFolder && typeof parentFolder.getFoldersByName === 'function' + ? parentFolder.getFoldersByName(folderName) + : null + return folders && typeof folders.hasNext === 'function' && folders.hasNext() + ? folders.next() + : null +} + +function getOrCreateChildFolder_(parentFolder, folderName, lockService) { + const existing = getExistingChildFolder_(parentFolder, folderName) + if (existing) { + return existing + } + const lockSvc = + lockService || (typeof LockService !== 'undefined' ? LockService : null) + const lock = + lockSvc && typeof lockSvc.getScriptLock === 'function' + ? lockSvc.getScriptLock() + : null + let acquired = false + if (lock && typeof lock.tryLock === 'function') { + try { + acquired = lock.tryLock(5000) + } catch {} + } + try { + return ( + getExistingChildFolder_(parentFolder, folderName) || + parentFolder.createFolder(folderName) + ) + } finally { + if (acquired && lock && typeof lock.releaseLock === 'function') { + try { + lock.releaseLock() + } catch { + // Ignore lock release errors + } + } + } +} + +function resolveDriveRootContainer_( + driveApp, + rootFolderName, + createIfMissing, + lockService +) { + const drive = + driveApp !== undefined + ? driveApp + : typeof DriveApp !== 'undefined' + ? DriveApp + : null + if (!drive) { + throw new Error('DriveApp service unavailable') + } + + let root = + typeof drive.getRootFolder === 'function' ? drive.getRootFolder() : drive + + if (!root || typeof root.getFoldersByName !== 'function') { + throw new Error('DriveApp service unavailable or invalid root folder') + } + + const trimmedRootName = + typeof rootFolderName === 'string' ? rootFolderName.trim() : '' + if (trimmedRootName) { + root = createIfMissing + ? getOrCreateChildFolder_(root, trimmedRootName, lockService) + : getExistingChildFolder_(root, trimmedRootName) + } + return root +} + +/** + * Idempotently traverses or creates the 2-level Drive taxonomy path (Domain / Subfolder), + * optionally nested under a configured root folder (`DRIVE_ROOT_FOLDER_NAME`). + * + * @param {string} canonicalDomain - e.g. "02_Finance_Legal" + * @param {string} subfolderName - e.g. "Banking" + * @param {Object} driveApp - GAS DriveApp service (injected) + * @param {string|Object} [rootFolderNameOrOptions] - Optional Drive container folder name or options ({ rootFolderName, lockService }) + * @returns {Object} Target folder object + */ +function ensureDriveTaxonomyFolder( + canonicalDomain, + subfolderName, + driveApp, + rootFolderNameOrOptions +) { + const rootFolderName = + typeof rootFolderNameOrOptions === 'string' + ? rootFolderNameOrOptions + : rootFolderNameOrOptions?.rootFolderName || '' + const lockService = + typeof rootFolderNameOrOptions === 'object' && rootFolderNameOrOptions + ? rootFolderNameOrOptions.lockService || + rootFolderNameOrOptions.LockService + : null + const root = resolveDriveRootContainer_( + driveApp, + rootFolderName, + true, + lockService + ) + const domainFolder = getOrCreateChildFolder_( + root, + canonicalDomain, + lockService + ) + if (!subfolderName) return domainFolder + + return getOrCreateChildFolder_(domainFolder, subfolderName, lockService) +} + +/** + * Non-mutating lookup of an existing 2-level Drive taxonomy folder. + * Returns null instead of creating folders when any segment does not exist. + * + * @param {string} canonicalDomain - e.g. "02_Finance_Legal" + * @param {string} subfolderName - e.g. "Banking" + * @param {Object} driveApp - GAS DriveApp service (injected) + * @param {string} [rootFolderName] - Optional Drive container folder name + * @returns {Object|null} Existing target folder or null + */ +function findDriveTaxonomyFolder_( + canonicalDomain, + subfolderName, + driveApp, + rootFolderName +) { + const root = resolveDriveRootContainer_(driveApp, rootFolderName, false) + if (!root) return null + const domainFolder = getExistingChildFolder_(root, canonicalDomain) + if (!domainFolder) return null + if (!subfolderName) return domainFolder + return getExistingChildFolder_(domainFolder, subfolderName) +} + +function extractBlobBytes_(blob) { + try { + if (typeof blob?.getBytes === 'function') { + return blob.getBytes() || Buffer.from('') + } + if (blob?.bytes) { + return blob.bytes + } + if (Buffer.isBuffer(blob)) { + return blob + } + } catch { + return Buffer.from('') + } + return Buffer.from('') +} + +function extractFileSize_(file) { + if (!file) return 0 + if (typeof file.getSize === 'function') { + return file.getSize() + } + if (file.size !== undefined) { + return file.size + } + return 0 +} + +/** + * Collects candidate existing Drive files for duplicate comparison, including both + * exact filename matches (`getFilesByName(fileName)`) and conflict-renamed variants + * (`stem_<timestamp>.ext`) when `folder.getFiles()` is available. + * + * @param {Object} folder - Drive folder + * @param {string} fileName - Original attachment filename + * @returns {{ hasNext: function(): boolean, next: function(): Object }} Iterator over candidate files + */ +function collectCandidateDriveFiles_(folder, fileName) { + const items = [] + const seenRefs = new Set() + + function addFromIterator(iter, filterFn) { + if (!iter || typeof iter.hasNext !== 'function') return + while (iter.hasNext()) { + const f = iter.next() + if (!f || seenRefs.has(f)) continue + if (filterFn && !filterFn(f)) continue + seenRefs.add(f) + items.push(f) + } + } + + if (folder && typeof folder.getFilesByName === 'function') { + addFromIterator(folder.getFilesByName(fileName)) + } + + if (folder && typeof folder.getFiles === 'function' && fileName) { + const clean = String(fileName).trim() + const dotIdx = clean.lastIndexOf('.') + const stem = (dotIdx > 0 ? clean.slice(0, dotIdx) : clean).toLowerCase() + const ext = dotIdx > 0 ? clean.slice(dotIdx).toLowerCase() : '' + + addFromIterator(folder.getFiles(), function (f) { + const fName = ( + typeof f.getName === 'function' ? f.getName() : f.name || '' + ).toLowerCase() + if (!fName) return false + if (fName === clean.toLowerCase()) return true + return fName.startsWith(stem + '_') && (ext === '' || fName.endsWith(ext)) + }) + } + + let idx = 0 + return { + hasNext: function () { + return idx < items.length + }, + next: function () { + return items[idx++] + }, + } +} + +/** + * Finds and returns the first existing file in `existingFiles` that matches + * `newFileBlob` by size and MD5 content hash, or null if none match. + * + * @param {Object} existingFiles - Iterator of Drive files + * @param {Object} newFileBlob - Blob of incoming file + * @param {Object} [helperFns] - Optional helper functions ({ getFileHash }) + * @returns {Object|null} Matching Drive file or null + */ +function findMatchingDuplicateFile_(existingFiles, newFileBlob, helperFns) { + if ( + !existingFiles || + typeof existingFiles.hasNext !== 'function' || + !newFileBlob + ) { + return null + } + const hashFn = helperFns?.getFileHash || getFileHash + const newFileBytes = extractBlobBytes_(newFileBlob) + const newFileLength = newFileBytes.length + let newFileHash = null + + while (existingFiles.hasNext()) { + const existingFile = existingFiles.next() + if (extractFileSize_(existingFile) !== newFileLength) { + continue + } + + if (newFileHash === null) { + try { + newFileHash = hashFn(newFileBlob) + } catch { + return null + } + } + + const existingBlob = + typeof existingFile.getBlob === 'function' + ? existingFile.getBlob() + : existingFile + if (hashFn(existingBlob) === newFileHash) { + return existingFile + } + } + return null +} + +/** + * Checks if an exact duplicate file already exists in target folder (size match + MD5 hash). + * + * @param {Object} existingFiles - Iterator from folder.getFilesByName + * @param {Object} newFileBlob - Blob of incoming file + * @param {Object} [helperFns] - Optional helper functions ({ getFileHash }) + * @returns {boolean} + */ +function isDuplicateAttachment(existingFiles, newFileBlob, helperFns) { + return Boolean( + findMatchingDuplicateFile_(existingFiles, newFileBlob, helperFns) + ) +} + +/** + * Resolves naming conflicts for attachments. If a file of the same name exists + * with different content, appends a timestamp before the extension. + * + * @param {Object} folder - Target Drive folder + * @param {string} fileName - Attachment filename + * @param {Object} newFileBlob - Blob being saved + * @param {Object} [options] - Injected GAS services ({ Utilities, Session }) + * @returns {string} Safe filename + */ +function resolveAttachmentName(folder, fileName, newFileBlob, options) { + const existingFiles = + folder && typeof folder.getFilesByName === 'function' + ? folder.getFilesByName(fileName) + : null + if ( + !existingFiles || + typeof existingFiles.hasNext !== 'function' || + !existingFiles.hasNext() + ) { + return fileName + } + + const utils = + options?.Utilities || (typeof Utilities !== 'undefined' ? Utilities : null) + const session = + options?.Session || (typeof Session !== 'undefined' ? Session : null) + + let timeTag = + utils && + session && + typeof utils.formatDate === 'function' && + typeof session.getScriptTimeZone === 'function' + ? utils.formatDate(new Date(), session.getScriptTimeZone(), '_HHmmssSSS') + : '_' + Date.now() + + if (typeof timeTag === 'string' && !timeTag.startsWith('_')) { + timeTag = '_' + timeTag.replace(/[^a-zA-Z0-9]/g, '') + } + + const renamed = fileName.replace(/(\.[\w-]+)$/i, timeTag + '$1') + const finalName = renamed === fileName ? fileName + timeTag : renamed + + if (newFileBlob && typeof newFileBlob.setName === 'function') { + newFileBlob.setName(finalName) + } + return finalName +} + +/** + * Unzips a canonical .zip archive into a dedicated subfolder within targetFolder. + * Filters out OS artifacts, checks document eligibility, skips duplicates, and tags files. + * + * @param {Folder} targetFolder - Parent Google Drive folder + * @param {Blob|File} zipBlobOrFile - The zip file or blob to extract + * @param {Object} [services] - Utilities, DriveApp, getFileHash + * @param {Object} [metadata] - { canonicalDomain, subfolderName, tagBlock, sourceFileName } + * @returns {Array} Array of saved file summary objects + */ +function extractZipArchiveToSubfolder( + targetFolder, + zipBlobOrFile, + services, + metadata +) { + if (!targetFolder) return [] + const utils = + services?.Utilities || (typeof Utilities !== 'undefined' ? Utilities : null) + if (!utils || typeof utils.unzip !== 'function') return [] + + let zipBlob = zipBlobOrFile + let fileName = '' + if (zipBlobOrFile && typeof zipBlobOrFile.getBlob === 'function') { + zipBlob = zipBlobOrFile.getBlob() + fileName = + typeof zipBlobOrFile.getName === 'function' ? zipBlobOrFile.getName() : '' + } else if (zipBlobOrFile && typeof zipBlobOrFile.getName === 'function') { + fileName = zipBlobOrFile.getName() + } + if (!fileName && metadata?.sourceFileName) { + fileName = metadata.sourceFileName + } + if (!fileName) fileName = 'archive.zip' + + const zipStem = fileName.replace(/\.zip$/i, '') + if (!zipStem) return [] + + const extractedSummaries = [] + try { + const unzippedBlobs = utils.unzip(zipBlob) + if (!unzippedBlobs || unzippedBlobs.length === 0) return [] + + const maxEntries = 25 + if (unzippedBlobs.length > maxEntries) { + console.warn( + '[extractZipArchiveToSubfolder] Archive "' + + fileName + + '" contains ' + + unzippedBlobs.length + + ' entries; truncating extraction to first ' + + maxEntries + + ' entries.' + ) + } + + const archiveFolder = getOrCreateChildFolder_(targetFolder, zipStem) + const extractLimit = Math.min(unzippedBlobs.length, maxEntries) + const helperFns = { + getFileHash: services?.getFileHash || getFileHash, + } + + for (let z = 0; z < extractLimit; z++) { + const entryBlob = unzippedBlobs[z] + if (!entryBlob) continue + const entryName = + typeof entryBlob.getName === 'function' + ? entryBlob.getName() + : 'unnamed' + + const slashIdx = Math.max( + entryName.lastIndexOf('/'), + entryName.lastIndexOf('\\') + ) + const baseEntryName = + slashIdx !== -1 ? entryName.substring(slashIdx + 1) : entryName + + if ( + !baseEntryName || + baseEntryName.charAt(0) === '.' || + entryName.indexOf('__MACOSX') !== -1 || + baseEntryName.toLowerCase() === 'thumbs.db' + ) { + continue + } + + if ( + typeof isEligibleAttachment === 'function' && + !isEligibleAttachment(entryBlob) + ) { + console.log( + '[extractZipArchiveToSubfolder] Skipped ineligible file inside zip: ' + + baseEntryName + ) + continue + } + + const existingCandidates = collectCandidateDriveFiles_( + archiveFolder, + baseEntryName + ) + + if ( + typeof isDuplicateAttachment === 'function' && + isDuplicateAttachment(existingCandidates, entryBlob, helperFns) + ) { + console.log( + '[extractZipArchiveToSubfolder] Skipped exact duplicate inside zip: ' + + baseEntryName + ) + continue + } + + const safeEntryName = resolveAttachmentName( + archiveFolder, + baseEntryName, + entryBlob, + services + ) + if (entryBlob && typeof entryBlob.setName === 'function') { + entryBlob.setName(safeEntryName) + } + const extractedFile = archiveFolder.createFile(entryBlob) + const extractedId = + typeof extractedFile.getId === 'function' ? extractedFile.getId() : '' + const extractedUrl = + typeof extractedFile.getUrl === 'function' + ? extractedFile.getUrl() + : 'https://drive.google.com/file/d/' + extractedId + + if (typeof extractedFile.setDescription === 'function') { + try { + const desc = + (metadata?.tagBlock ? metadata.tagBlock + '\n' : '') + + 'Extracted-From: ' + + fileName + extractedFile.setDescription(desc) + } catch {} + } + + const cDomain = metadata?.canonicalDomain || '' + const sFolder = metadata?.subfolderName || '' + + extractedSummaries.push({ + name: + zipStem + + '/' + + (typeof extractedFile.getName === 'function' + ? extractedFile.getName() + : safeEntryName), + url: extractedUrl, + id: extractedId, + domain: cDomain, + subfolder: sFolder ? sFolder + '/' + zipStem : zipStem, + }) + + console.log( + '[extractZipArchiveToSubfolder] Auto-unzipped: ' + + (cDomain ? cDomain + '/' : '') + + (sFolder ? sFolder + '/' : '') + + zipStem + + '/' + + safeEntryName + ) + } + } catch (unzipErr) { + console.warn( + '[extractZipArchiveToSubfolder] Auto-unzip warning for ' + + fileName + + ': ' + + unzipErr.message + ) + } + + return extractedSummaries +} + +/** + * Unzips an existing .zip file in Google Drive into a subfolder named after the zip stem. + * + * @param {string} fileIdOrName - File ID or file name in targetFolder + * @param {Folder} targetFolder - Drive folder containing the zip file + * @param {Object} [services] - { DriveApp, Utilities } + * @returns {Array} Array of extracted file summaries + */ +function unzipExistingDriveArchive(fileIdOrName, targetFolder, services) { + const drive = + services?.DriveApp || (typeof DriveApp !== 'undefined' ? DriveApp : null) + if (!drive && !targetFolder) { + console.error( + '[unzipExistingDriveArchive] DriveApp or targetFolder required.' + ) + return [] + } + + let zipFile = null + let parentFolder = targetFolder + + if (targetFolder && typeof targetFolder.getFilesByName === 'function') { + const files = targetFolder.getFilesByName(fileIdOrName) + if (files && files.hasNext && files.hasNext()) { + zipFile = files.next() + } + } + + if (!zipFile && drive && typeof drive.getFileById === 'function') { + try { + zipFile = drive.getFileById(fileIdOrName) + if (!parentFolder && typeof zipFile.getParents === 'function') { + const parents = zipFile.getParents() + if (parents && parents.hasNext && parents.hasNext()) { + parentFolder = parents.next() + } + } + } catch {} + } + + if (!zipFile || !parentFolder) { + console.error( + '[unzipExistingDriveArchive] Could not locate zip file: ' + fileIdOrName + ) + return [] + } + + return extractZipArchiveToSubfolder(parentFolder, zipFile, services, { + sourceFileName: zipFile.getName(), + }) +} + +/** + * Persists attached documents from a canonical email thread to Google Drive along + * the label's taxonomy path, skipping duplicates and strictly ignoring non-canonical emails. + * + * @param {Object} thread - Gmail thread object + * @param {Object} classification - AI classification result + * @param {Object} config - Classifier config + * @param {Object} services - Injected GAS services ({ DriveApp, Utilities, Session }) + * @returns {Object[]} Array of saved file metadata ({ name, url, id, domain, subfolder }) + */ +function persistCanonicalAttachmentsToDrive( + thread, + classification, + config, + services +) { + // STRICT NON-CANONICAL GATE: If email has no canonical domain, ignore attachments completely! + if (!isCanonicalClassification(classification, config)) { + console.log( + '[persistCanonicalAttachmentsToDrive] Non-canonical or null domain; skipping attachment persistence.' + ) + return [] + } + + let driveApp = services?.DriveApp + if (typeof driveApp === 'undefined') { + driveApp = typeof DriveApp !== 'undefined' ? DriveApp : null + } + if (!driveApp) { + console.error( + '[persistCanonicalAttachmentsToDrive] DriveApp is unavailable; cannot persist attachments.' + ) + return [] + } + + const canonicalDomain = + classification.canonicalDomain || classification.canonical_label + const subLabel = classification.subLabel || '' + const subfolderName = resolveTaxonomySubfolderName(canonicalDomain, subLabel) + + let targetFolder + try { + targetFolder = ensureDriveTaxonomyFolder( + canonicalDomain, + subfolderName, + driveApp, + config?.driveRootFolderName + ) + } catch (err) { + console.error( + '[persistCanonicalAttachmentsToDrive] Failed to ensure taxonomy folder ' + + canonicalDomain + + '/' + + subfolderName + + ': ' + + err.message + ) + return [] + } + + const messages = + typeof thread.getMessages === 'function' ? thread.getMessages() : [] + const savedFiles = [] + const helperFns = { + getFileHash: services?.getFileHash || getFileHash, + } + const subject = + classification?.title || + (typeof thread.getFirstMessageSubject === 'function' + ? thread.getFirstMessageSubject() + : thread.subject || '') + + messages.forEach((msg) => { + const attachments = getMessageAttachments_(msg) + attachments.forEach((att) => { + if (!isEligibleAttachment(att)) { + console.log( + '[persistCanonicalAttachmentsToDrive] Skipped ineligible attachment (signature/tracking pixel or empty): ' + + (typeof att.getName === 'function' ? att.getName() : 'unnamed') + ) + return + } + + const fileName = + typeof att.getName === 'function' ? att.getName() : att.name + const newFileBlob = + typeof att.copyBlob === 'function' ? att.copyBlob() : att + const existingFiles = collectCandidateDriveFiles_(targetFolder, fileName) + + if (isDuplicateAttachment(existingFiles, newFileBlob, helperFns)) { + console.log( + '[persistCanonicalAttachmentsToDrive] Skipped exact duplicate attachment: ' + + fileName + ) + return + } + + const finalName = resolveAttachmentName( + targetFolder, + fileName, + newFileBlob, + services + ) + console.log( + '[persistCanonicalAttachmentsToDrive] Saving attachment to ' + + canonicalDomain + + '/' + + subfolderName + + ': ' + + finalName + ) + + try { + const file = targetFolder.createFile(newFileBlob) + const fileId = typeof file.getId === 'function' ? file.getId() : '' + const fileUrl = + typeof file.getUrl === 'function' + ? file.getUrl() + : 'https://drive.google.com/file/d/' + fileId + + const tagDate = + services?.Utilities && + typeof services.Utilities.formatDate === 'function' + ? services.Utilities.formatDate( + new Date(), + 'GMT', + "yyyy-MM-dd'T'HH:mm:ss'Z'" + ) + : new Date().toISOString() + const tagBlock = + '[AI_INDEXED] ' + + tagDate + + '\nDomain: ' + + canonicalDomain + + '\nSub-label: ' + + (classification.subLabel || '') + + '\nSource: Gmail Attachment' + + '\nThread-ID: ' + + (typeof thread.getId === 'function' + ? thread.getId() + : thread.id || '') + + (subject ? '\nEmail-Subject: ' + subject : '') + + if (typeof file.setDescription === 'function') { + try { + file.setDescription(tagBlock) + } catch { + // Non-blocking metadata description tagging + } + } + + savedFiles.push({ + name: typeof file.getName === 'function' ? file.getName() : finalName, + url: fileUrl, + id: fileId, + domain: canonicalDomain, + subfolder: subfolderName, + }) + + // Auto-Unzip Enhancement for .zip archives + if (fileName.toLowerCase().endsWith('.zip')) { + const extractedFiles = extractZipArchiveToSubfolder( + targetFolder, + newFileBlob, + services, + { + canonicalDomain: canonicalDomain, + subfolderName: subfolderName, + tagBlock: tagBlock, + sourceFileName: fileName, + } + ) + if (extractedFiles && extractedFiles.length > 0) { + for (let ef = 0; ef < extractedFiles.length; ef++) { + savedFiles.push(extractedFiles[ef]) + } + } + } + } catch (saveErr) { + console.error( + '[persistCanonicalAttachmentsToDrive] Error saving file ' + + finalName + + ': ' + + saveErr.message + ) + Object.defineProperty(savedFiles, 'hadErrors', { + value: true, + enumerable: false, + configurable: true, + }) + } + }) + }) + + return savedFiles +} + +/** + * Resolves the canonical domain (e.g. "01_Household") from thread labels. + * Inspects both root domain identifiers and canonical sub-label prefixes. + * + * @param {string[]} rawLabels - Array of label names on the thread + * @param {string[]} [canonicalDomains] - Optional list of canonical domain keys + * @returns {string|null} Canonical domain key or null if non-canonical + */ +function resolveCanonicalDomainFromLabels(rawLabels, canonicalDomains) { + if (!rawLabels || !Array.isArray(rawLabels)) return null + const allowed = canonicalDomains || [ + '01_Household', + '02_Finance_Legal', + '03_Vehicles', + '04_Family_Health', + '05_Tech_Infrastructure', + '06_Work_Career', + '07_Community_NonProfit', + ] + // 1. Direct match on root domain codes (e.g. "01_Household") + for (let i = 0; i < rawLabels.length; i++) { + const l = rawLabels[i] + if (allowed.includes(l) || /^0[1-7]_/.test(l)) { + return l + } + } + // 2. Canonical sub-label prefix mapping + for (let j = 0; j < rawLabels.length; j++) { + const lName = (rawLabels[j] || '').toLowerCase() + if (lName.startsWith('household/')) return '01_Household' + if (lName.startsWith('finance/')) return '02_Finance_Legal' + if (lName.startsWith('vehicles/')) return '03_Vehicles' + if (lName.startsWith('family/')) return '04_Family_Health' + if (lName.startsWith('tech/')) return '05_Tech_Infrastructure' + if (lName.startsWith('work/')) return '06_Work_Career' + if ( + lName.startsWith('community/') || + lName.startsWith('projects/') || + lName.startsWith('nonprofit/') + ) + return '07_Community_NonProfit' + } + return null +} + +/** + * Scans historical Gmail threads for attachments across the 7 Canonical Domains, + * verifies whether each attachment exists in Google Drive under the correct taxonomy path, + * checks/applies native [AI_INDEXED] description tags, and optionally backfills missing files. + * + * @param {Object} [options] - Execution options + * @param {boolean} [options.dryRun=true] - If true, only reports discrepancies; if false, uploads missing attachments and tags them. + * @param {string} [options.query] - Custom search query (defaults to searching canonical domain labels with attachments). + * @param {number} [options.maxThreads=50] - Maximum threads to inspect in this batch. + * @param {Object} [config] - Classifier configuration (defaults to getAiClassifierConfig()). + * @param {Object} [services] - Service injection for testing ({ GmailApp, DriveApp, Utilities }). + * @returns {Object} report + */ +function auditAndBackfillCanonicalAttachments(options, config, services) { + const opts = options || {} + const isDryRun = opts.dryRun !== false + const maxThreads = opts.maxThreads || 50 + + const cfg = resolveEffectiveConfig_(config) + const canonicalDomains = cfg.canonicalDomains || [ + '01_Household', + '02_Finance_Legal', + '03_Vehicles', + '04_Family_Health', + '05_Tech_Infrastructure', + '06_Work_Career', + '07_Community_NonProfit', + ] + + const gmail = + services?.GmailApp || (typeof GmailApp !== 'undefined' ? GmailApp : null) + const drive = + services?.DriveApp || (typeof DriveApp !== 'undefined' ? DriveApp : null) + const utils = + services?.Utilities || (typeof Utilities !== 'undefined' ? Utilities : null) + + if (!gmail || !drive) { + throw new Error( + 'GmailApp and DriveApp services are required for attachment audit/backfill.' + ) + } + + const defaultCanonicalQuery = + 'has:attachment (label:01_Household OR label:02_Finance_Legal OR label:03_Vehicles OR label:04_Family_Health OR label:05_Tech_Infrastructure OR label:06_Work_Career OR label:07_Community_NonProfit OR label:Household OR label:Finance OR label:Vehicles OR label:Family OR label:Tech OR label:Work OR label:Community OR label:Projects OR label:NonProfit)' + const searchQuery = opts.query || defaultCanonicalQuery + + let threads = [] + if (typeof gmail.search === 'function') { + threads = sortThreadsByLastMessageDate_( + gmail.search(searchQuery, 0, maxThreads) || [] + ) + } + + const report = { + scannedThreads: threads.length, + threadsWithEligibleAttachments: 0, + totalAttachmentsInspected: 0, + totalEligibleAttachments: 0, + filteredGarbageCount: 0, + eligibleByType: {}, + filteredByReason: {}, + alreadyStoredAndTagged: 0, + alreadyStoredUntagged: 0, + missingFromDrive: 0, + backfilledCount: 0, + taggedCount: 0, + dryRun: isDryRun, + items: [], + } + + const startTime = Date.now() + const timeBudgetMs = opts.timeBudgetMs || 270000 + const folderCache = {} + + for (let i = 0; i < threads.length; i++) { + if (Date.now() - startTime > timeBudgetMs) { + console.warn( + '[auditAndBackfillCanonicalAttachments] Execution reached time budget safety ceiling; concluding batch cleanly.' + ) + report.timeBudgetReached = true + break + } + + const thread = threads[i] + if (!thread) continue + const threadId = + typeof thread.getId === 'function' ? thread.getId() : thread.id || '' + const subject = + typeof thread.getFirstMessageSubject === 'function' + ? thread.getFirstMessageSubject() + : thread.subject || '' + + let rawLabels = [] + if (typeof thread.getLabels === 'function') { + const labelObjs = thread.getLabels() || [] + rawLabels = labelObjs.map((l) => + typeof l.getName === 'function' ? l.getName() : String(l) + ) + } else if (Array.isArray(thread.labels)) { + rawLabels = thread.labels + } + + const canonicalDomain = resolveCanonicalDomainFromLabels( + rawLabels, + canonicalDomains + ) + if (!canonicalDomain) continue + + const isTaxonomySubLabel = (l) => + Boolean( + l && + l.includes('/') && + !l.toLowerCase().startsWith('ai/') && + !l.startsWith('Retention/') && + !l.includes('Archives') + ) + const subLabel = + rawLabels.find( + (l) => isTaxonomySubLabel(l) && !l.includes(canonicalDomain) + ) || + rawLabels.find((l) => isTaxonomySubLabel(l)) || + '' + const subfolderName = resolveTaxonomySubfolderName( + canonicalDomain, + subLabel + ) + + const msgs = + typeof thread.getMessages === 'function' + ? thread.getMessages() + : thread.messages || [] + const eligibleAtts = [] + + msgs.forEach((msg) => { + const atts = getMessageAttachments_(msg) + atts.forEach((att) => { + report.totalAttachmentsInspected++ + const evalResult = evaluateAttachmentEligibility(att) + if (!evalResult.eligible) { + report.filteredGarbageCount++ + const reasonKey = evalResult.reason.split(':')[0] + report.filteredByReason[reasonKey] = + (report.filteredByReason[reasonKey] || 0) + 1 + return + } + eligibleAtts.push(att) + }) + }) + + if (eligibleAtts.length === 0) { + continue + } + + const folderKey = `${canonicalDomain}::${subfolderName}` + let targetFolder = folderCache[folderKey] + if (targetFolder === undefined) { + try { + targetFolder = isDryRun + ? findDriveTaxonomyFolder_( + canonicalDomain, + subfolderName, + drive, + cfg.driveRootFolderName + ) + : ensureDriveTaxonomyFolder( + canonicalDomain, + subfolderName, + drive, + cfg.driveRootFolderName + ) + folderCache[folderKey] = targetFolder + } catch (err) { + console.error( + '[auditAndBackfillCanonicalAttachments] Error accessing folder ' + + canonicalDomain + + '/' + + subfolderName + + ': ' + + err.message + ) + continue + } + } + + report.threadsWithEligibleAttachments++ + + eligibleAtts.forEach((att) => { + report.totalEligibleAttachments++ + const fileName = + typeof att.getName === 'function' + ? att.getName() + : att.name || 'unnamed' + const cleanName = fileName.trim() + const dotIndex = cleanName.lastIndexOf('.') + const ext = + dotIndex > 0 ? cleanName.slice(dotIndex + 1).toLowerCase() : 'other' + report.eligibleByType[ext] = (report.eligibleByType[ext] || 0) + 1 + + let fileSize = extractFileSize_(att) + if (!fileSize && typeof att.getBytes === 'function') { + const b = att.getBytes() + fileSize = b ? b.length : 0 + } + const fileSizeKb = Math.round((fileSize / 1024) * 10) / 10 + const mimeType = + typeof att.getContentType === 'function' + ? att.getContentType() + : att.contentType || '' + + const newFileBlob = + typeof att.copyBlob === 'function' ? att.copyBlob() : att + + const existingFiles = targetFolder + ? collectCandidateDriveFiles_(targetFolder, fileName) + : null + const helperFns = { + getFileHash: getFileHash, + Utilities: utils, + } + + const foundFile = findMatchingDuplicateFile_( + existingFiles, + newFileBlob, + helperFns + ) + + const tagDate = + utils && typeof utils.formatDate === 'function' + ? utils.formatDate(new Date(), 'GMT', "yyyy-MM-dd'T'HH:mm:ss'Z'") + : new Date().toISOString() + const tagBlock = + '[AI_INDEXED] ' + + tagDate + + '\nDomain: ' + + canonicalDomain + + '\nSub-label: ' + + subLabel + + '\nSource: Gmail Attachment Backfill' + + '\nThread-ID: ' + + threadId + + '\nEmail-Subject: ' + + subject + + if (foundFile) { + const desc = + typeof foundFile.getDescription === 'function' + ? foundFile.getDescription() + : '' + + if (desc && desc.includes('[AI_INDEXED]')) { + report.alreadyStoredAndTagged++ + report.items.push({ + threadId: threadId, + subject: subject, + fileName: fileName, + fileSizeKb: fileSizeKb, + mimeType: mimeType, + extension: ext, + domain: canonicalDomain, + subfolder: subfolderName, + status: 'ALREADY_STORED_AND_TAGGED', + }) + } else { + report.alreadyStoredUntagged++ + if (!isDryRun && typeof foundFile.setDescription === 'function') { + try { + foundFile.setDescription(tagBlock) + report.taggedCount++ + } catch { + // Non-blocking + } + } + report.items.push({ + threadId: threadId, + subject: subject, + fileName: fileName, + fileSizeKb: fileSizeKb, + mimeType: mimeType, + extension: ext, + domain: canonicalDomain, + subfolder: subfolderName, + status: isDryRun ? 'STORED_BUT_UNTAGGED' : 'TAGGED_EXISTING', + }) + } + } else { + report.missingFromDrive++ + if (!isDryRun && targetFolder) { + try { + const finalName = resolveAttachmentName( + targetFolder, + fileName, + newFileBlob, + services + ) + const createdFile = targetFolder.createFile(newFileBlob) + if ( + createdFile && + typeof createdFile.setDescription === 'function' + ) { + createdFile.setDescription(tagBlock) + } + report.backfilledCount++ + report.items.push({ + threadId: threadId, + subject: subject, + fileName: finalName, + fileSizeKb: fileSizeKb, + mimeType: mimeType, + extension: ext, + domain: canonicalDomain, + subfolder: subfolderName, + status: 'BACKFILLED_TO_DRIVE', + }) + } catch (saveErr) { + report.items.push({ + threadId: threadId, + subject: subject, + fileName: fileName, + fileSizeKb: fileSizeKb, + mimeType: mimeType, + extension: ext, + domain: canonicalDomain, + subfolder: subfolderName, + status: 'BACKFILL_ERROR', + error: saveErr.message, + }) + } + } else { + report.items.push({ + threadId: threadId, + subject: subject, + fileName: fileName, + fileSizeKb: fileSizeKb, + mimeType: mimeType, + extension: ext, + domain: canonicalDomain, + subfolder: subfolderName, + status: 'MISSING_FROM_DRIVE', + }) + } + } + }) + } + + return report +} + +/** + * Convenience runner for dry-run historical attachment audit. + */ +function runDryRunAttachmentAudit(options, config, services) { + const opts = Object.assign({ dryRun: true, maxThreads: 50 }, options || {}) + const report = auditAndBackfillCanonicalAttachments(opts, config, services) + + console.log('===============================================================') + console.log(' CANONICAL ATTACHMENT AUDIT MANIFEST (DRY RUN)') + console.log('===============================================================') + console.log( + 'Scanned Threads: ' + + report.scannedThreads + + ' | Total Attachments Inspected: ' + + report.totalAttachmentsInspected + + ' | Eligible: ' + + report.totalEligibleAttachments + + ' | Filtered Garbage: ' + + report.filteredGarbageCount + ) + console.log('---------------------------------------------------------------') + console.log('ELIGIBLE ATTACHMENTS BY TYPE:') + Object.keys(report.eligibleByType || {}).forEach(function (type) { + console.log(' .' + type + ': ' + report.eligibleByType[type]) + }) + console.log('FILTERED GARBAGE BY REASON:') + Object.keys(report.filteredByReason || {}).forEach(function (reason) { + console.log(' ' + reason + ': ' + report.filteredByReason[reason]) + }) + console.log('---------------------------------------------------------------') + console.log('CURATED FILES SCHEDULED FOR DRIVE STORAGE:') + if (!report.items || report.items.length === 0) { + console.log(' (None - zero files qualify for Drive storage)') + } else { + report.items.forEach(function (item, idx) { + console.log( + ' [' + + (idx + 1) + + '] [' + + item.domain + + '/' + + item.subfolder + + '] ' + + item.fileName + + ' (' + + item.fileSizeKb + + ' KB) - Subject: "' + + item.subject + + '"' + ) + }) + } + console.log('===============================================================') + + return report +} + +/** + * Convenience runner for live historical attachment backfill. + */ +function runLiveAttachmentBackfill(options, config, services) { + const opts = Object.assign({ dryRun: false, maxThreads: 50 }, options || {}) + return auditAndBackfillCanonicalAttachments(opts, config, services) +} + +/** + * Detects whether a first argument passed to a trigger handler is a Google Apps Script + * time-driven event object rather than a classifier configuration object. + * + * @param {*} obj - Candidate argument + * @returns {boolean} True if obj is a GAS trigger event payload + */ +function isTriggerEventObject_(obj) { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { + return false + } + const hasClassifierConfigKey = + 'geminiApiKey' in obj || + 'canonicalDomains' in obj || + 'unprocessedQuery' in obj || + 'processedLabel' in obj || + 'auditLookbackDays' in obj || + 'auditDigestEmail' in obj || + 'userAccountEmail' in obj || + 'driveApp' in obj + if (hasClassifierConfigKey) { + return false + } + return ( + 'triggerUid' in obj || + 'authMode' in obj || + ('year' in obj && 'month' in obj && 'hour' in obj) + ) +} + +/** + * Resolves the effective classifier configuration, ignoring GAS time-driven + * trigger event objects when functions are invoked directly by ScriptApp triggers. + * + * @param {Object} [customConfig] - Optional custom config or trigger event object + * @returns {Object} Effective configuration + */ +function resolveEffectiveConfig_(customConfig) { + if ( + customConfig && + typeof customConfig === 'object' && + !isTriggerEventObject_(customConfig) + ) { + return customConfig + } + return typeof getAiClassifierConfig === 'function' + ? getAiClassifierConfig() + : {} +} + +/** + * Extracts a numeric timestamp (ms) from a Gmail thread for deterministic sorting. + * + * @param {Object} thread - GmailThread or mock object + * @returns {number} Epoch milliseconds or 0 if unavailable + */ +function getThreadLastMessageTimestamp_(thread) { + if (!thread || typeof thread !== 'object') return 0 + try { + const d = + typeof thread.getLastMessageDate === 'function' + ? thread.getLastMessageDate() + : thread.lastMessageDate + if (d instanceof Date && !Number.isNaN(d.getTime())) return d.getTime() + if (typeof d === 'number' && Number.isFinite(d)) return d + if (typeof d === 'string') { + const parsed = Date.parse(d) + if (!Number.isNaN(parsed)) return parsed + } + } catch { + // Non-blocking fallback + } + return 0 +} + +/** + * Sorts an array of Gmail threads by last-message date (newest first), + * preserving original order when timestamps are equal or absent. + * + * @param {Array} threads - Array of GmailThread objects + * @returns {Array} Sorted shallow copy of threads + */ +function sortThreadsByLastMessageDate_(threads) { + if (!Array.isArray(threads) || threads.length <= 1) { + return Array.isArray(threads) ? threads.slice() : [] + } + return threads + .map(function (thread, index) { + return { + thread: thread, + index: index, + ts: getThreadLastMessageTimestamp_(thread), + } + }) + .sort(function (a, b) { + if (b.ts !== a.ts) { + return b.ts - a.ts + } + return a.index - b.index + }) + .map(function (entry) { + return entry.thread + }) +} + +/** + * Verifies that a candidate Drive file in `oldFolder` actually belongs to the + * thread attachment being relocated (`Thread-ID` description match or size/hash match), + * preventing unrelated files that share a generic filename (e.g. `invoice.pdf`) from being moved. + * + * @param {Object} fileCandidate - Drive file object + * @param {Object} att - Gmail attachment object + * @param {string} threadId - Current Gmail thread ID + * @param {Object} [helperFns] - Optional helper functions ({ getFileHash }) + * @returns {boolean} True if fileCandidate matches the thread attachment + */ +function doesDriveFileMatchThreadAttachment_( + fileCandidate, + att, + threadId, + helperFns +) { + if (!fileCandidate || !att) return false + const desc = + typeof fileCandidate.getDescription === 'function' + ? fileCandidate.getDescription() || '' + : fileCandidate.description || '' + if (desc && desc.indexOf('Thread-ID:') !== -1) { + return Boolean(threadId) && desc.indexOf('Thread-ID: ' + threadId) !== -1 + } + + const candidateSize = extractFileSize_(fileCandidate) + const attSize = extractFileSize_(att) || extractBlobBytes_(att).length + if (candidateSize > 0 && attSize > 0 && candidateSize !== attSize) { + return false + } + + const hashFn = (helperFns && helperFns.getFileHash) || getFileHash + const candidateBlob = + typeof fileCandidate.getBlob === 'function' + ? fileCandidate.getBlob() + : fileCandidate + const attBlob = typeof att.copyBlob === 'function' ? att.copyBlob() : att + const candidateHash = hashFn(candidateBlob) + const attHash = hashFn(attBlob) + if (candidateHash && attHash) { + return candidateHash === attHash + } + + return true +} + +/** + * Reclassifies historical threads matching a custom Gmail search query. + * Useful for retroactive alignment of misclassified or misattributed threads. + * + * @param {string} searchQuery - Gmail search query + * @param {Object} [options] - Options: { maxThreads: 20, dryRun: false } + * @param {Object} [config] - Classifier config + * @param {Object} [services] - Dependency injection for testing { GmailApp, Utilities, classifyFn } + * @returns {Object} Report of reclassified threads + */ +function reclassifyThreadsByQuery(searchQuery, options, config, services) { + const opts = Object.assign( + { maxThreads: 20, dryRun: false, timeBudgetMs: 240000 }, + options || {} + ) + const cfg = resolveEffectiveConfig_(config) + const gmail = + (services && services.GmailApp) || + (typeof GmailApp !== 'undefined' ? GmailApp : null) + const classifyFn = + (services && services.classifyFn) || + function (s, sub, sn, c) { + return classifyWithGemini(s, sub, sn, c, services) + } + + if (!gmail) { + throw new Error('GmailApp service is required for reclassifying threads.') + } + + console.log( + '[reclassifyThreadsByQuery] Query: ' + + searchQuery + + ' (dryRun: ' + + opts.dryRun + + ', maxThreads: ' + + opts.maxThreads + + ')' + ) + + const rawThreads = gmail.search(searchQuery, 0, opts.maxThreads) || [] + const threads = sortThreadsByLastMessageDate_(rawThreads) + console.log( + '[reclassifyThreadsByQuery] Found ' + + threads.length + + ' thread(s) matching query.' + ) + + const results = [] + const startTime = Date.now() + const timeBudgetMs = opts.timeBudgetMs || 240000 + + for (let i = 0; i < threads.length; i++) { + if (Date.now() - startTime > timeBudgetMs) { + console.warn( + '[reclassifyThreadsByQuery] Execution safety ceiling reached (' + + Math.round((Date.now() - startTime) / 1000) + + 's); completing batch cleanly.' + ) + break + } + + const thread = threads[i] + const threadId = + typeof thread.getId === 'function' + ? thread.getId() + : thread.id || String(i) + const msgs = + typeof thread.getMessages === 'function' ? thread.getMessages() : [] + if (!msgs || msgs.length === 0) continue + const firstMessage = msgs[0] + const sender = + typeof firstMessage.getFrom === 'function' ? firstMessage.getFrom() : '' + const subject = + typeof firstMessage.getSubject === 'function' + ? firstMessage.getSubject() + : '' + let snippet = + typeof firstMessage.getPlainBody === 'function' + ? firstMessage.getPlainBody().substring(0, 500) + : '' + snippet = enrichSnippetWithAttachments_(snippet, firstMessage) + + let rawLabels = [] + if (typeof thread.getLabels === 'function') { + const labelObjs = thread.getLabels() || [] + rawLabels = labelObjs.map(function (l) { + return typeof l.getName === 'function' ? l.getName() : String(l) + }) + } + + let classification = null + try { + classification = + deterministicRuleClassify_(sender, subject, snippet, thread, cfg) || + classifyFn(sender, subject, snippet, cfg) + } catch (e) { + console.warn( + '[reclassifyThreadsByQuery] Classifier exception: ' + e.message + ) + } + if (!classification) continue + let oldDomain = resolveCanonicalDomainFromLabels( + rawLabels, + cfg.canonicalDomains + ) + if (!oldDomain) { + const oldCanonical = rawLabels.filter(function (l) { + return ( + (cfg.canonicalDomains || []).indexOf(l) !== -1 || /^0[1-7]_/.test(l) + ) + }) + oldDomain = oldCanonical.length > 0 ? oldCanonical[0] : null + } + const oldSubLabel = + rawLabels.find(function (l) { + return ( + l.indexOf('/') !== -1 && + l !== oldDomain && + l.toLowerCase().indexOf('ai/') !== 0 && + l.indexOf('Archives') === -1 && + l.indexOf('Retention/') === -1 + ) + }) || '' + + const newDomain = + classification.canonicalDomain || classification.canonical_label + const newSubLabel = classification.subLabel || '' + const tldChanged = oldDomain && newDomain && oldDomain !== newDomain + + const attachmentsMoved = [] + let attachmentsSaved = [] + + if (!opts.dryRun) { + cleanConflictingLabels( + thread, + classification.canonicalDomain, + classification.subLabel, + cfg + ) + let primaryTag = classification.subLabel + if (!primaryTag || isRootDomainLabel(primaryTag)) { + const domainKey = + classification.canonicalDomain || classification.canonical_label + primaryTag = + (domainKey && DEFAULT_DOMAIN_SUBLABEL_MAP[domainKey]) || + domainKey || + null + } + if (primaryTag && typeof thread.addLabel === 'function') { + const targetLabel = ensureGmailLabel(primaryTag, gmail) + thread.addLabel(targetLabel) + } + if ( + classification.category && + classification.category !== 'Primary' && + classification.action !== 'trash' + ) { + classification.action = 'archive' + } + if (classification.category && classification.action !== 'trash') { + setGmailCategoryTab(thread, classification.category) + } + + const userSent = isThreadSentOrRepliedByUser(thread, cfg.userAccountEmail) + const requestedDestructive = + classification.action === 'archive' || classification.action === 'trash' + const destructiveOk = isDestructiveActionAllowed_(classification) + + if (requestedDestructive && !userSent && destructiveOk) { + if ( + classification.action === 'trash' && + typeof thread.moveToTrash === 'function' + ) { + thread.moveToTrash() + } else if ( + classification.action === 'archive' && + typeof thread.moveToArchive === 'function' + ) { + thread.moveToArchive() + } + } + + if (cfg.processedLabel && typeof thread.addLabel === 'function') { + const processedLabel = ensureGmailLabel(cfg.processedLabel, gmail) + thread.addLabel(processedLabel) + } + + // Drive attachment relocation & backfill + const drive = + (services && services.DriveApp) || + (typeof DriveApp !== 'undefined' ? DriveApp : null) + if (drive && isCanonicalClassification(classification, cfg)) { + const oldSubfolder = oldDomain + ? resolveTaxonomySubfolderName(oldDomain, oldSubLabel) + : null + const newSubfolder = resolveTaxonomySubfolderName( + newDomain, + newSubLabel + ) + + // Relocate existing attachments if folder path changed + if ( + oldDomain && + oldSubfolder && + (oldDomain !== newDomain || oldSubfolder !== newSubfolder) + ) { + try { + const oldFolder = + findDriveTaxonomyFolder_( + oldDomain, + oldSubfolder, + drive, + cfg.driveRootFolderName + ) || + ensureDriveTaxonomyFolder( + oldDomain, + oldSubfolder, + drive, + cfg.driveRootFolderName + ) + const newFolder = ensureDriveTaxonomyFolder( + newDomain, + newSubfolder, + drive, + cfg.driveRootFolderName + ) + const helperFns = { + getFileHash: services?.getFileHash || getFileHash, + } + + msgs.forEach(function (msg) { + const atts = getMessageAttachments_(msg) + atts.forEach(function (att) { + if (!isEligibleAttachment(att)) return + const attName = + typeof att.getName === 'function' ? att.getName() : '' + if (!attName || !oldFolder) return + + const existingFiles = collectCandidateDriveFiles_( + oldFolder, + attName + ) + while ( + existingFiles && + typeof existingFiles.hasNext === 'function' && + existingFiles.hasNext() + ) { + const fileToMove = existingFiles.next() + if ( + typeof fileToMove.moveTo === 'function' && + doesDriveFileMatchThreadAttachment_( + fileToMove, + att, + threadId, + helperFns + ) + ) { + fileToMove.moveTo(newFolder) + attachmentsMoved.push({ + name: attName, + from: oldDomain + '/' + oldSubfolder, + to: newDomain + '/' + newSubfolder, + }) + console.log( + '[reclassifyThreadsByQuery] Relocated attachment "' + + attName + + '" from ' + + oldDomain + + '/' + + oldSubfolder + + ' to ' + + newDomain + + '/' + + newSubfolder + ) + break + } + } + }) + }) + } catch (e) { + console.warn( + '[reclassifyThreadsByQuery] Drive relocation warning: ' + + e.message + ) + } + } + + // Persist any eligible attachments not yet preserved + const saved = persistCanonicalAttachmentsToDrive( + thread, + classification, + cfg, + { + DriveApp: drive, + Utilities: + services && services.Utilities + ? services.Utilities + : typeof Utilities !== 'undefined' + ? Utilities + : null, + } + ) + if (saved && saved.length > 0) { + attachmentsSaved = saved + } + } + } + + const labelChanged = oldDomain !== newDomain || oldSubLabel !== newSubLabel + const summary = { + threadId: threadId, + subject: subject, + sender: sender, + oldDomain: oldDomain, + oldSubLabel: oldSubLabel, + newDomain: newDomain, + newSubLabel: newSubLabel, + labelChanged: labelChanged, + tldChanged: !!tldChanged, + action: classification.action, + category: classification.category, + attachmentsMoved: attachmentsMoved, + attachmentsSaved: attachmentsSaved, + } + + results.push(summary) + } + + if (opts.dryRun) { + console.log( + '===============================================================' + ) + console.log( + ' HISTORICAL REALIGNMENT AUDIT (DRY RUN) ' + ) + console.log( + '===============================================================' + ) + console.log('Query: ' + searchQuery) + console.log('Scanned: ' + threads.length + ' thread(s)') + console.log('Audited: ' + results.length) + results.forEach(function (item, idx) { + console.log( + ' [' + + (idx + 1) + + '] Thread: ' + + item.threadId + + ' | Subject: "' + + item.subject + + '"' + ) + console.log( + ' Old: ' + + (item.oldSubLabel || item.oldDomain || '(none)') + + ' -> New: ' + + (item.newSubLabel || item.newDomain) + + (item.labelChanged ? ' [CHANGES]' : ' [MATCHES]') + ) + console.log( + ' TLD Changed: ' + + item.tldChanged + + ' | Action: ' + + item.action + + ' | Category: ' + + item.category + ) + if (item.attachmentsMoved && item.attachmentsMoved.length > 0) { + console.log( + ' Attachments to move: ' + JSON.stringify(item.attachmentsMoved) + ) + } + }) + console.log( + '===============================================================' + ) + } + + return { + query: searchQuery, + scanned: threads.length, + reclassified: results.length, + dryRun: opts.dryRun, + items: results, + } +} + +/** + * Automatically identifies career/employment emails from the past 14 days that were + * missed or prematurely labeled 'Processed' without receiving a canonical career tag. + * Reverts 'Processed' and applies 'AI/Needs-Retry' to durably queue them for classification. + */ +function revertAndQueueMissedCareerEmails(gmailApp, config) { + const gmail = gmailApp || (typeof GmailApp !== 'undefined' ? GmailApp : null) + if (!gmail || typeof gmail.search !== 'function') return [] + + const cfg = resolveEffectiveConfig_(config) + const retryLabelName = cfg.retryLabel || 'AI/Needs-Retry' + const processedLabelName = cfg.processedLabel || 'Processed' + + const retryLabel = ensureGmailLabel(retryLabelName, gmail) + const processedLabel = ensureGmailLabel(processedLabelName, gmail) + + const recoverySearch = + 'newer_than:14d (subject:"Employment Agreement" OR subject:"Employment Contract" OR subject:"Offer Letter" OR subject:"PwC" OR subject:"KPMG")' + + const modifiedThreadIds = [] + try { + const rawThreads = gmail.search(recoverySearch, 0, 20) || [] + const threads = sortThreadsByLastMessageDate_(rawThreads) + for (let i = 0; i < threads.length; i++) { + const thread = threads[i] + if (isThreadSentOrRepliedByUser(thread, cfg.userAccountEmail)) { + continue + } + let labels = [] + if (typeof thread.getLabels === 'function') { + const lObjs = thread.getLabels() || [] + labels = lObjs.map(function (l) { + return typeof l.getName === 'function' ? l.getName() : String(l) + }) + } + const hasCareerTag = + labels.indexOf('06_Work_Career') !== -1 || + labels.indexOf('Work/Career') !== -1 || + labels.indexOf('Career/DJ') !== -1 || + labels.indexOf('Career/Rachel') !== -1 || + labels.indexOf('Career_Interviews') !== -1 + + if (!hasCareerTag) { + let changed = false + if (processedLabel && labels.indexOf(processedLabelName) !== -1) { + try { + thread.removeLabel(processedLabel) + changed = true + } catch {} + } + if (retryLabel && labels.indexOf(retryLabelName) === -1) { + try { + thread.addLabel(retryLabel) + changed = true + } catch {} + } + if (changed) { + const tId = + typeof thread.getId === 'function' ? thread.getId() : String(i) + modifiedThreadIds.push(tId) + console.log( + '[revertAndQueueMissedCareerEmails] Reverted Processed and queued thread: ' + + tId + ) + } + } + } + } catch (err) { + console.warn( + '[revertAndQueueMissedCareerEmails] Search or labeling error:', + err.message + ) + } + return modifiedThreadIds +} + +/** + * Convenience runner for re-evaluating recent emails from the past 14 days + * or emails tagged with AI/Needs-Retry that may have been missed. + */ +function runMissedEmailsSweep(config, services) { + const gmail = + (services && services.GmailApp) || + (typeof GmailApp !== 'undefined' ? GmailApp : null) + const cfg = resolveEffectiveConfig_(config) + if (typeof revertAndQueueMissedCareerEmails === 'function') { + revertAndQueueMissedCareerEmails(gmail, cfg) + } + + // 1. Direct Priority Recovery: First sweep and reclassify career & employment contract emails + const careerQuery = + 'newer_than:14d (subject:"Employment Agreement" OR subject:"Employment Contract" OR subject:"Offer Letter" OR subject:"PwC" OR subject:"KPMG")' + console.log('[runMissedEmailsSweep] Priority career sweep: ' + careerQuery) + reclassifyThreadsByQuery( + careerQuery, + { maxThreads: 20, dryRun: false }, + cfg, + services + ) + + // 2. Secondary General Recovery: Sweep other unclassified threads + const query = + 'label:AI/Needs-Retry OR (newer_than:14d -label:Processed -label:AI/Manual-Review)' + console.log('[runMissedEmailsSweep] Sweeping query: ' + query) + return reclassifyThreadsByQuery( + query, + { maxThreads: 30, dryRun: false }, + cfg, + services + ) +} + +/** + * Convenience runner to audit historical threads matching a query in dry-run mode. + */ +function runRealignmentAudit(query, options, config, services) { + const gmail = + (services && services.GmailApp) || + (typeof GmailApp !== 'undefined' ? GmailApp : null) + let searchQuery = query + + if (!searchQuery && gmail && typeof gmail.getUserLabels === 'function') { + try { + const allLabels = gmail.getUserLabels() || [] + const matchedLabels = allLabels + .filter(function (l) { + const name = typeof l.getName === 'function' ? l.getName() : String(l) + return /sister/i.test(name) + }) + .map(function (l) { + const name = typeof l.getName === 'function' ? l.getName() : String(l) + return 'label:"' + name + '"' + }) + + console.log( + '[runRealignmentAudit] Discovered sister labels in Gmail: ' + + JSON.stringify(matchedLabels) + ) + + const queryParts = matchedLabels.slice() + queryParts.push('"name change"') + searchQuery = queryParts.join(' OR ') + } catch (e) { + console.warn( + '[runRealignmentAudit] Error discovering user labels: ' + e.message + ) + } + } + + if (!searchQuery) { + searchQuery = 'label:"Family/Sisters" OR "name change"' + } + + return reclassifyThreadsByQuery( + searchQuery, + Object.assign({ maxThreads: 25, dryRun: true }, options || {}), + config, + services + ) +} + +/** + * Sweeps and reclassifies current unread items in the inbox against the + * Category-Driven Retention Invariant, archiving all non-Primary streams. + * + * @param {Object} [options] - Reclassification options (maxThreads, dryRun, timeBudgetMs) + * @param {Object} [customConfig] - Optional override config + * @param {Object} [customServices] - Optional injected services + * @returns {Object} Realignment summary report + */ +function sweepAllUnreadInboxEmails(options, customConfig, customServices) { + const opts = Object.assign({ maxThreads: 50, dryRun: false }, options || {}) + return reclassifyThreadsByQuery( + 'in:inbox is:unread -is:starred -from:me', + opts, + customConfig, + customServices + ) +} + +/** + * Checks if a thread contains any messages sent or replied by the account owner. + */ +function isThreadSentOrRepliedByUser(thread, userEmail) { + try { + const primaryEmail = (userEmail || '').toLowerCase() + if (!primaryEmail || !thread || typeof thread.getMessages !== 'function') { + return false + } + const messages = thread.getMessages() || [] + for (let m = 0; m < messages.length; m++) { + const msg = messages[m] + if (!msg || typeof msg.getFrom !== 'function') continue + const rawFrom = msg.getFrom() + const fromAddr = ( + rawFrom && typeof rawFrom === 'string' ? rawFrom : '' + ).toLowerCase() + if (fromAddr.indexOf(primaryEmail) !== -1) { + return true + } + } + } catch (e) { + console.warn( + '[isThreadSentOrRepliedByUser] Error checking message senders:', + e.message + ) + } + return false +} + +/** + * Automatically purges emails older than their category retention policy (2 years for Promotions/Social/Forums). + * Core household domains (01_Household - 07_Community_NonProfit), Primary, Updates, Starred, and User Replied/Sent threads are 100% EXEMPT. + */ +function purgeExpiredEmailsByRetentionPolicy(customConfig, customServices) { + console.log( + '[purgeExpiredEmailsByRetentionPolicy] Starting weekly category retention cleanup...' + ) + const services = customServices || {} + const config = resolveEffectiveConfig_(customConfig) + + const gmail = + services.GmailApp || (typeof GmailApp !== 'undefined' ? GmailApp : null) + if (!gmail) { + console.error( + '[purgeExpiredEmailsByRetentionPolicy] GmailApp service is missing.' + ) + return { trashedCount: 0, exemptedCount: 0 } + } + + // Explicitly exclude threads sent by user or containing user replies + const retentionQuery = + '(category:promotions OR category:social OR category:forums) older_than:2y -is:starred -from:me' + + const coreDomainLabels = config.canonicalDomains || [ + '01_Household', + '02_Finance_Legal', + '03_Vehicles', + '04_Family_Health', + '05_Tech_Infrastructure', + '06_Work_Career', + '07_Community_NonProfit', + ] + const pageSize = 50 + const maxPages = 5 + let trashedCount = 0 + let exemptedCount = 0 + let totalFound = 0 + let offset = 0 + + for (let page = 0; page < maxPages; page++) { + const rawPage = gmail.search(retentionQuery, offset, pageSize) || [] + const threads = sortThreadsByLastMessageDate_(rawPage) + if (page === 0) { + console.log( + '[purgeExpiredEmailsByRetentionPolicy] Found ' + + threads.length + + ' expired thread(s) matching 2-year retention query.' + ) + if (threads.length === 0) { + return { trashedCount: 0, exemptedCount: 0 } + } + } else if (threads.length === 0) { + break + } + + totalFound += threads.length + let pageRemainingCount = 0 + + for (let i = 0; i < threads.length; i++) { + try { + const thread = threads[i] + if (!thread) continue + const labels = + typeof thread.getLabels === 'function' ? thread.getLabels() || [] : [] + let isExempt = false + + // Safety Shield 1: Check if thread contains any Core Household Domain label + // OR any classifier sub-label. + for (let l = 0; l < labels.length; l++) { + const labelName = + typeof labels[l].getName === 'function' + ? labels[l].getName() + : String(labels[l]) + for (let cd = 0; cd < coreDomainLabels.length; cd++) { + if (labelName.indexOf(coreDomainLabels[cd]) !== -1) { + isExempt = true + break + } + } + if (isExempt) break + if ( + labelName.indexOf('/') !== -1 && + labelName.indexOf('Retention/') !== 0 + ) { + isExempt = true + break + } + } + + // Safety Shield 2: Check if user sent a message or replied in this thread + if (!isExempt) { + if (isThreadSentOrRepliedByUser(thread, config.userAccountEmail)) { + isExempt = true + } + } + + if (!isExempt) { + if (typeof thread.moveToTrash === 'function') { + thread.moveToTrash() + } + trashedCount++ + } else { + exemptedCount++ + pageRemainingCount++ + const subj = + typeof thread.getFirstMessageSubject === 'function' + ? thread.getFirstMessageSubject() + : thread.getId() + console.log( + '[purgeExpiredEmailsByRetentionPolicy] Exempted thread from trash (contains core domain label or user reply): ' + + subj + ) + } + } catch (err) { + pageRemainingCount++ + console.warn( + '[purgeExpiredEmailsByRetentionPolicy] Error processing thread: ' + + err.message + ) + } + } + + offset += pageRemainingCount + if (rawPage.length < pageSize) { + break + } + } + + console.log( + '[purgeExpiredEmailsByRetentionPolicy] Retention cleanup complete. Scanned ' + + totalFound + + ' thread(s); moved ' + + trashedCount + + ' expired thread(s) to Trash.' + ) + return { trashedCount: trashedCount, exemptedCount: exemptedCount } +} + +const DESTRUCTIVE_CONFIDENCE_MIN = 0.85 + +function isDestructiveActionAllowed_(classification) { + if (!classification || typeof classification !== 'object') { + return false + } + if ( + classification.action !== 'trash' && + classification.action !== 'archive' + ) { + return false + } + const confidence = classification.confidence + if (!Number.isFinite(confidence)) { + return false + } + return confidence >= DESTRUCTIVE_CONFIDENCE_MIN +} + +const GMAIL_AI_CLASSIFIER_VERSION = 'v1.8.0-drive-taxonomy-attachments' + +/** + * Main entry point for Gemini AI-Powered Semantic Email Classification and Auto-Filter Engine. + * Supports injected config and services for testing and headless execution. + * + * @param {Object} [customConfig] - Optional override config + * @param {Object} [customServices] - Optional injected services + */ +function processEmailsWithAiClassifier(customConfig, customServices) { + const services = customServices || {} + const lockService = + services.LockService || + (typeof LockService !== 'undefined' ? LockService : null) + let lock = null + if (lockService && typeof lockService.getScriptLock === 'function') { + lock = lockService.getScriptLock() + let hasLock = false + try { + hasLock = lock.tryLock(10000) + } catch (e) { + console.warn( + '[processEmailsWithAiClassifier] Error attempting script lock:', + e.message + ) + } + if (!hasLock) { + console.warn( + '[processEmailsWithAiClassifier] Could not obtain script lock after 10s. Another execution is in progress. Exiting.' + ) + return + } + } + + try { + console.log( + '[processEmailsWithAiClassifier] Engine Version: ' + + GMAIL_AI_CLASSIFIER_VERSION + ) + console.log( + '[processEmailsWithAiClassifier] Starting AI semantic email processing...' + ) + const hasExplicitCustomConfig = Boolean( + customConfig && !isTriggerEventObject_(customConfig) + ) + const config = resolveEffectiveConfig_(customConfig) + + if (!config.geminiApiKey) { + console.error( + '[processEmailsWithAiClassifier] GEMINI_API_KEY ScriptProperty is missing.' + ) + return + } + + const gmail = + services.GmailApp || (typeof GmailApp !== 'undefined' ? GmailApp : null) + if (!gmail) { + console.error( + '[processEmailsWithAiClassifier] GmailApp service is missing.' + ) + return + } + + if (!hasExplicitCustomConfig) { + revertAndQueueMissedCareerEmails(gmail, config) + } + + const rawPrimaryThreads = gmail.search(config.unprocessedQuery, 0, 10) || [] + let threads = sortThreadsByLastMessageDate_(rawPrimaryThreads) + console.log( + '[processEmailsWithAiClassifier] Found ' + + threads.length + + ' unprocessed thread(s).' + ) + + const retryLabelName = config.retryLabel || 'AI/Needs-Retry' + const manualReviewLabelName = config.manualReviewLabel || 'AI/Manual-Review' + + let isSecondarySweep = false + if (threads.length === 0) { + // Secondary sweep: Look for unread unstarred inbox threads to realign non-Primary items + const sweepQuery = + 'in:inbox is:unread -is:starred -from:me -label:' + + manualReviewLabelName + const rawSweepThreads = gmail.search(sweepQuery, 0, 15) || [] + threads = sortThreadsByLastMessageDate_(rawSweepThreads) + isSecondarySweep = true + console.log( + '[processEmailsWithAiClassifier] Secondary sweep found ' + + threads.length + + ' unread inbox thread(s) for realignment.' + ) + if (threads.length === 0) { + return + } + } + + const processedLabel = ensureGmailLabel( + config.processedLabel, + gmail, + services.Gmail + ) + const retryLabel = ensureGmailLabel(retryLabelName, gmail, services.Gmail) + const manualReviewLabel = ensureGmailLabel( + manualReviewLabelName, + gmail, + services.Gmail + ) + + const props = + (services.PropertiesService && + typeof services.PropertiesService.getScriptProperties === 'function' + ? services.PropertiesService.getScriptProperties() + : null) || + services.properties || + (typeof PropertiesService !== 'undefined' && + typeof PropertiesService.getScriptProperties === 'function' + ? PropertiesService.getScriptProperties() + : null) + + const cache = + (services.CacheService && + typeof services.CacheService.getScriptCache === 'function' + ? services.CacheService.getScriptCache() + : null) || + services.cache || + (typeof CacheService !== 'undefined' && + typeof CacheService.getScriptCache === 'function' + ? CacheService.getScriptCache() + : null) + + const startTime = Date.now() + let consecutiveRemoteFailures = 0 + let remoteQuotaExhausted = false + + if (isCircuitBreakerOpen(props)) { + const untilMs = Number.parseInt( + props.getProperty('GEMINI_BACKOFF_UNTIL'), + 10 + ) + console.warn( + '[processEmailsWithAiClassifier] Circuit breaker is OPEN until ' + + (Number.isNaN(untilMs) + ? 'unknown' + : new Date(untilMs).toISOString()) + + '; skipping remote AI calls for this execution.' + ) + remoteQuotaExhausted = true + } + + for (let i = 0; i < threads.length; i++) { + if (Date.now() - startTime > 270000) { + console.warn( + '[processEmailsWithAiClassifier] Safety time budget reached (270s); stopping batch gracefully.' + ) + break + } + const thread = threads[i] + const messages = + typeof thread.getMessages === 'function' ? thread.getMessages() : [] + if (!messages || messages.length === 0) continue + const firstMessage = messages[0] + const sender = + typeof firstMessage.getFrom === 'function' + ? firstMessage.getFrom() + : firstMessage.from || '' + const subject = + typeof firstMessage.getSubject === 'function' + ? firstMessage.getSubject() + : firstMessage.subject || '' + let snippet = + typeof firstMessage.getPlainBody === 'function' + ? firstMessage.getPlainBody().substring(0, 500) + : firstMessage.snippet || '' + snippet = enrichSnippetWithAttachments_(snippet, firstMessage) + + console.log( + '[processEmailsWithAiClassifier] Processing (' + + (i + 1) + + '/' + + threads.length + + '): ' + + subject + + ' from ' + + sender + ) + + let threadLabels = [] + if (typeof thread.getLabels === 'function') { + try { + const lList = thread.getLabels() || [] + threadLabels = lList.map(function (l) { + return typeof l.getName === 'function' ? l.getName() : String(l) + }) + } catch {} + } + const isAlreadyProcessed = + config.processedLabel && + threadLabels.indexOf(config.processedLabel) !== -1 + const isManualReview = threadLabels.indexOf(manualReviewLabelName) !== -1 + + let classification = deterministicRuleClassify_( + sender, + subject, + snippet, + thread, + config + ) + let usedRemoteApi = false + if ( + !classification && + !remoteQuotaExhausted && + !isManualReview && + (!isSecondarySweep || !isAlreadyProcessed) + ) { + const classifyFn = + services.classifyFn || + function (s, sub, sn, cfg) { + return classifyWithGemini(s, sub, sn, cfg, services) + } + classification = classifyFn(sender, subject, snippet, config) + usedRemoteApi = true + if (!classification) { + consecutiveRemoteFailures++ + recordCircuitBreakerFailure(props) + if (consecutiveRemoteFailures >= 2) { + console.warn( + '[processEmailsWithAiClassifier] Multiple consecutive remote API failures detected; disabling remote API for remainder of batch to preserve execution budget.' + ) + remoteQuotaExhausted = true + } + } else { + consecutiveRemoteFailures = 0 + recordCircuitBreakerSuccess(props) + } + } + if (classification) { + cleanConflictingLabels( + thread, + classification.canonicalDomain, + classification.subLabel, + config + ) + + let primaryTag = classification.subLabel + if (!primaryTag || isRootDomainLabel(primaryTag)) { + const domainKey = + classification.canonicalDomain || classification.canonical_label + primaryTag = + (domainKey && DEFAULT_DOMAIN_SUBLABEL_MAP[domainKey]) || + domainKey || + null + } + if (primaryTag) { + const targetLabel = ensureGmailLabel( + primaryTag, + gmail, + services.Gmail + ) + if (targetLabel) { + thread.addLabel(targetLabel) + } + + const savedAttachments = persistCanonicalAttachmentsToDrive( + thread, + classification, + config, + services + ) + if (savedAttachments && savedAttachments.hadErrors) { + const attRetryCount = incrementThreadRetryCount_(thread, cache) + if (attRetryCount >= 5 && manualReviewLabel) { + thread.addLabel(manualReviewLabel) + } else if (retryLabel) { + thread.addLabel(retryLabel) + } + console.warn( + '[processEmailsWithAiClassifier] Attachment persistence failed; queued thread for retry without marking Processed.' + ) + continue + } + + if (config.githubToken) { + try { + const notePath = getNotePathForDomain( + classification.canonicalDomain, + classification.subLabel + ) + const utils = + services.Utilities || + (typeof Utilities !== 'undefined' ? Utilities : null) + if (notePath && utils) { + const msgDate = + typeof firstMessage.getDate === 'function' + ? firstMessage.getDate() + : new Date() + const dateStr = utils.formatDate(msgDate, 'GMT', 'yyyy-MM-dd') + const entryMd = formatProgressiveDisclosureEntry( + dateStr, + classification.title || subject, + sender, + subject, + classification.summary, + config.userAccountEmail, + savedAttachments + ) + assertNoAsciiReplacement_( + (subject || '') + (classification.summary || ''), + entryMd + ) + assertClean_(entryMd, 'new entry for ' + notePath) + appendMarkdownEntryToGitHubRepo( + notePath, + entryMd, + 'feat(ingestion): ' + subject, + services + ) + } + } catch (ghErr) { + console.warn( + '[processEmailsWithAiClassifier] Non-blocking GitHub sync error for ' + + subject + + ': ' + + ghErr.message + ) + } + } + } + + if ( + classification.category && + classification.category !== 'Primary' && + classification.action !== 'trash' + ) { + classification.action = 'archive' + } + + const userSent = isThreadSentOrRepliedByUser( + thread, + config.userAccountEmail + ) + const requestedDestructive = + classification.action === 'trash' || + classification.action === 'archive' + const destructiveOk = isDestructiveActionAllowed_(classification) + if (requestedDestructive && !userSent && !destructiveOk) { + console.warn( + '[processEmailsWithAiClassifier] Shield: Skipped destructive action "' + + classification.action + + '" (low/invalid confidence ' + + classification.confidence + + '); preserved thread in INBOX.' + ) + } else if (requestedDestructive && !userSent) { + if (classification.action === 'trash') { + thread.moveToTrash() + console.log( + '[processEmailsWithAiClassifier] Action: Moved spam/unwanted thread to TRASH.' + ) + } else { + thread.moveToArchive() + console.log( + '[processEmailsWithAiClassifier] Action: Archived thread out of INBOX.' + ) + } + } + + if (classification.action === 'mark_read' || classification.markRead) { + thread.markRead() + console.log( + '[processEmailsWithAiClassifier] Action: Marked thread as READ.' + ) + } + + if (classification.category && classification.action !== 'trash') { + setGmailCategoryTab(thread, classification.category, services.Gmail) + } + + // Remove retryLabel if present + if (retryLabel && threadLabels.indexOf(retryLabelName) !== -1) { + try { + thread.removeLabel(retryLabel) + console.log( + '[processEmailsWithAiClassifier] Removed retry label from successfully classified thread.' + ) + } catch (rErr) { + console.warn( + '[processEmailsWithAiClassifier] Failed to remove retry label:', + rErr.message + ) + } + } + clearThreadRetryCount_(thread, cache) + + if (processedLabel) { + thread.addLabel(processedLabel) + } + } else { + if (usedRemoteApi) { + const retryCount = incrementThreadRetryCount_(thread, cache) + if (retryCount >= 5) { + if (retryLabel && threadLabels.indexOf(retryLabelName) !== -1) { + try { + thread.removeLabel(retryLabel) + } catch {} + } + if (manualReviewLabel) { + thread.addLabel(manualReviewLabel) + } + console.warn( + '[processEmailsWithAiClassifier] Classification failed after ' + + retryCount + + ' attempts; escalated to ' + + manualReviewLabelName + + ' for manual review.' + ) + } else { + if (retryLabel) { + thread.addLabel(retryLabel) + } + console.warn( + '[processEmailsWithAiClassifier] Classification failed or returned null; tagged with ' + + retryLabelName + + ' (attempt ' + + retryCount + + '/5) to ensure durable retry.' + ) + } + } else { + console.warn( + '[processEmailsWithAiClassifier] Remote classification skipped for thread; preserving untouched for next execution.' + ) + } + } + + if (usedRemoteApi) { + const utilsService = + services.Utilities || + (typeof Utilities !== 'undefined' ? Utilities : null) + if (utilsService && typeof utilsService.sleep === 'function') { + utilsService.sleep(2000) + } + } + } + + console.log('[processEmailsWithAiClassifier] Batch processing complete.') + } finally { + if (lock) { + try { + lock.releaseLock() + } catch { + // ignore lock release error + } + } + } } if (typeof module !== 'undefined' && module.exports) { module.exports = { - processEmailsWithAiClassifier: processEmailsWithAiClassifier, - setupFiveMinuteTrigger: setupFiveMinuteTrigger, - stopAllTriggers: stopAllTriggers, - classifyWithGemini: classifyWithGemini, - ensureUserLabel: ensureUserLabel, - createGmailFilterRule: createGmailFilterRule, + getAiClassifierConfig: + typeof getAiClassifierConfig !== 'undefined' + ? getAiClassifierConfig + : undefined, + getClassifierConfig: + typeof getClassifierConfig !== 'undefined' + ? getClassifierConfig + : undefined, + processEmailsWithAiClassifier, + purgeExpiredEmailsByRetentionPolicy, + isThreadSentOrRepliedByUser, + isDestructiveActionAllowed_, + validateClassification, + validateOntologicalClassification, + classifyEmailWithGemini, + buildOntologicalPrompt, + ensureGmailLabel, + ensureUserLabel: ensureGmailLabel, + createPermanentGmailFilter, + createGmailFilterRule, + processThreadBatch, + RULE6_PATTERNS, + assertClean_, + assertNoAsciiReplacement_, + extractTopicTitleFromPath, + insertEntryIntoLogSection, + formatProgressiveDisclosureEntry, + getNotePathForDomain, + appendMarkdownEntryToGitHubRepo, + executeGitHubCommit, + getSearchDateRange_, + auditClassifications, + formatAuditDigest, + auditEmailClassifications, + setupWeeklyRetentionTrigger, + setupDailyAuditTrigger, + setupFourHourAuditTrigger, + setupFiveMinuteTrigger, + stopAllTriggers, + isCanonicalClassification, + resolveTaxonomySubfolderName, + evaluateAttachmentEligibility, + isEligibleAttachment, + getMessageAttachments_, + ensureDriveTaxonomyFolder, + findDriveTaxonomyFolder_, + isDuplicateAttachment, + resolveAttachmentName, + extractZipArchiveToSubfolder, + unzipExistingDriveArchive, + persistCanonicalAttachmentsToDrive, + auditAndBackfillCanonicalAttachments, + runDryRunAttachmentAudit, + runLiveAttachmentBackfill, + setGmailCategoryTab, + cleanConflictingLabels, + reclassifyThreadsByQuery, + runRealignmentAudit, + runMissedEmailsSweep, + revertAndQueueMissedCareerEmails, + sweepAllUnreadInboxEmails, + CANONICAL_TAXONOMY_SUBFOLDERS, + SUBLABEL_TO_FOLDER_MAP, + DEFAULT_DOMAIN_SUBLABEL_MAP, + isRootDomainLabel, + resolveCanonicalDomainFromLabels, + deterministicRuleClassify_, + enrichSnippetWithAttachments_, + getFileHash, + classifyWithGemini, + extractJsonSubstring, + parseRetryDelayMs, + computeStringHash, + listAvailableGeminiModels, + isCircuitBreakerOpen, + recordCircuitBreakerFailure, + recordCircuitBreakerSuccess, + getThreadRetryCount_, + incrementThreadRetryCount_, + clearThreadRetryCount_, + isTriggerEventObject_, + resolveEffectiveConfig_, + sortThreadsByLastMessageDate_, } } diff --git a/src/gmail-ai-classifier/config.gs b/src/gmail-ai-classifier/config.gs index a52ecd66..6e889f33 100644 --- a/src/gmail-ai-classifier/config.gs +++ b/src/gmail-ai-classifier/config.gs @@ -1,36 +1,105 @@ -/* istanbul ignore file */ -/* sonar.javascript.skipCoverage */ /** * Configuration schema for Gemini AI Semantic Email Classifier & Auto-Filter Engine - * Supports Multi-Account Household Deployment (Option A: user@example.com & partner@example.com) + * Supports Multi-Account Household Deployment (Option A) */ -// NOSONAR — GAS configuration object; structure used by main script but line-by-line instrumentation not feasible + function getAiClassifierConfig() { - var props = PropertiesService.getScriptProperties() - var activeUserEmail = - Session.getEffectiveUser().getEmail() || - props.getProperty('USER_ACCOUNT_EMAIL') || - 'household-member@gmail.com' - var isDon = activeUserEmail.toLowerCase().indexOf('partner') !== -1 - var partnerEmail = isDon ? 'user@example.com' : 'partner@example.com' + var props = + typeof PropertiesService !== 'undefined' && + PropertiesService && + typeof PropertiesService.getScriptProperties === 'function' + ? PropertiesService.getScriptProperties() + : null + var getProp = function (key) { + return props && typeof props.getProperty === 'function' + ? props.getProperty(key) + : null + } + var modelName = getProp('GEMINI_MODEL') || 'gemini-2.5-flash' + + // Automatically upgrade deprecated models (gemini-1.5, gemini-2.0) to gemini-2.5-flash + if ( + !modelName || + modelName === 'gemini-2.0-flash' || + modelName.indexOf('gemini-1.5') !== -1 || + modelName.indexOf('gemini-2.0') !== -1 + ) { + modelName = 'gemini-2.5-flash' + } + + var customEndpoint = getProp('GEMINI_ENDPOINT') || '' + if ( + customEndpoint && + (customEndpoint.indexOf('gemini-1.5') !== -1 || + customEndpoint.indexOf('gemini-2.0') !== -1) + ) { + customEndpoint = '' + } + + var userEmail = null + try { + if ( + typeof Session !== 'undefined' && + Session && + typeof Session.getEffectiveUser === 'function' && + Session.getEffectiveUser() && + typeof Session.getEffectiveUser().getEmail === 'function' + ) { + userEmail = Session.getEffectiveUser().getEmail() + } + } catch (e) { + // Session access might be restricted in certain scopes + } + + var rawLookback = getProp('AUDIT_LOOKBACK_DAYS') + var parsedLookback = Number.parseInt(rawLookback || '2', 10) + var auditLookbackDays = + Number.isNaN(parsedLookback) || parsedLookback <= 0 ? 2 : parsedLookback + + var rawThreshold = getProp('AUTO_FILTER_THRESHOLD') + var parsedThreshold = Number.parseFloat( + rawThreshold !== null && rawThreshold !== undefined && rawThreshold !== '' + ? rawThreshold + : '0.95' + ) + var autoFilterConfidenceThreshold = Number.isNaN(parsedThreshold) + ? 0.95 + : Math.min(1, Math.max(0, parsedThreshold)) return { - geminiApiKey: props.getProperty('GEMINI_API_KEY') || '', - githubToken: props.getProperty('GITHUB_PAT') || '', - userAccountEmail: activeUserEmail, - partnerEmail: partnerEmail, - processedLabel: 'Processed', - unprocessedQuery: 'in:inbox -label:Processed', - autoFilterConfidenceThreshold: 0.95, + geminiApiKey: getProp('GEMINI_API_KEY') || '', + geminiModel: modelName, + familyEmails: getProp('FAMILY_EMAILS') || '', + githubToken: getProp('GITHUB_PAT') || '', + githubRepoOwner: getProp('GITHUB_REPO_OWNER') || '', + githubRepoName: getProp('GITHUB_REPO_NAME') || '', + driveRootFolderName: getProp('DRIVE_ROOT_FOLDER_NAME') || '', + userAccountEmail: + userEmail || + getProp('USER_ACCOUNT_EMAIL') || + 'household-member@gmail.com', + customPromptRules: getProp('CUSTOM_PROMPT_RULES') || '', + auditDigestEmail: getProp('AUDIT_DIGEST_EMAIL') || '', + auditLookbackDays: auditLookbackDays, + processedLabel: getProp('PROCESSED_LABEL') || 'Processed', + retryLabel: getProp('RETRY_LABEL') || 'AI/Needs-Retry', + manualReviewLabel: getProp('MANUAL_REVIEW_LABEL') || 'AI/Manual-Review', + unprocessedQuery: + getProp('UNPROCESSED_QUERY') || + 'label:AI/Needs-Retry OR (in:inbox -label:Processed -label:AI/Manual-Review) OR (newer_than:14d -label:Processed -label:AI/Manual-Review -from:me)', + autoFilterConfidenceThreshold: autoFilterConfidenceThreshold, modelEndpoint: - 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash-lite:generateContent', + customEndpoint || + 'https://generativelanguage.googleapis.com/v1beta/models/' + + modelName + + ':generateContent', // 7 Canonical Domain Folders & Label Tree canonicalDomains: [ '01_Household', '02_Finance_Legal', '03_Vehicles', - '04_Family_Health_School', + '04_Family_Health', '05_Tech_Infrastructure', '06_Work_Career', '07_Community_NonProfit', @@ -38,12 +107,10 @@ function getAiClassifierConfig() { } } -/* istanbul ignore next */ function getClassifierConfig() { return getAiClassifierConfig() } -/* istanbul ignore if */ if (typeof module !== 'undefined' && module.exports) { module.exports = { getAiClassifierConfig, getClassifierConfig } } diff --git a/src/gmail-ai-classifier/gitHubSync.gs b/src/gmail-ai-classifier/gitHubSync.gs index ce91875a..eb0af567 100644 --- a/src/gmail-ai-classifier/gitHubSync.gs +++ b/src/gmail-ai-classifier/gitHubSync.gs @@ -1,62 +1,142 @@ -/* istanbul ignore file */ -/* sonar.javascript.skipCoverage */ /** * Native Google Apps Script GitHub Sync Module (Auto-Create & Long Document Aware) - * Calls GitHub REST API via UrlFetchApp to commit Markdown entries directly to self-private repo. + * Calls GitHub REST API via UrlFetchApp to commit Markdown entries directly to a configured GitHub repository. * * Features: * - Auto-creates missing target markdown files on GitHub (HTTP 404 handling) * - SHA Collision & Retry Guard (catches HTTP 409 API conflicts & retries) * - Section-Aware Targeted Insertion (inserts entries into Section 3 log blocks) * - Idempotency Guard (skips duplicate entries) - * NOSONAR — contains multiple similar error-handling blocks across API operations, intentional for robustness */ -var GITHUB_REPO_OWNER = 'user-org' -var GITHUB_REPO_NAME = 'self-private' - -function getGitHubApiUrl_(filePath) { - return ( - 'https://api.github.com/repos/' + - GITHUB_REPO_OWNER + - '/' + - GITHUB_REPO_NAME + - '/contents/' + - filePath - ) +var GITHUB_SYNC_RULE6_PATTERNS_ = [ + [/\S \? \S/, "' ? ' between words (was an em dash or a · separator)"], + [/\?\?/, "'??' (was a multi-codepoint emoji)"], + [/[A-Za-z]\?[A-Za-z]/, "'?' inside a word (was a curly apostrophe)"], + [/\uFFFD/, 'U+FFFD replacement character'], +] + +/** Refuse to write an entry that already shows mojibake. */ +function assertClean_(text, what) { + if (!text) return + var stripped = String(text).replace(/https?:\/\/\S+/g, '') + for (var i = 0; i < GITHUB_SYNC_RULE6_PATTERNS_.length; i++) { + if (GITHUB_SYNC_RULE6_PATTERNS_[i][0].test(stripped)) { + throw new Error( + 'Rule 6: refusing to write ' + + what + + ' — ' + + GITHUB_SYNC_RULE6_PATTERNS_[i][1] + ) + } + } } -function getGitHubHeaders_(token) { - return { - Authorization: 'token ' + token, - Accept: 'application/vnd.github.v3+json', - 'User-Agent': 'Google-Apps-Script', +/** Refuse to write when a non-ASCII char in the source text became '?' on the way out. */ +function assertNoAsciiReplacement_(source, rendered) { + if (!source || !rendered) return + if (rendered.indexOf('?') === -1) return + var lost = [] + for (var i = 0; i < source.length; i++) { + var c = source.charAt(i) + if ( + c.charCodeAt(0) > 127 && + rendered.indexOf(c) === -1 && + lost.indexOf(c) === -1 + ) { + lost.push(c) + } + } + if (lost.length) { + throw new Error( + 'Rule 6: refusing to write text that flattened non-ASCII to "?": ' + + lost.join(' ') + + ' — encode as UTF-8, not ASCII.' + ) } } -function getGitHubToken_(overrideToken) { - return ( - overrideToken || - PropertiesService.getScriptProperties().getProperty('GITHUB_PAT') - ) -} +/** + * Resolves GitHub repository owner and name lazily from services, ScriptProperties, config, or environment. + */ +function resolveGitHubRepoTarget_(services) { + var propsService = + (services && services.PropertiesService) || + (typeof PropertiesService !== 'undefined' ? PropertiesService : null) + var scriptProps = + propsService && typeof propsService.getScriptProperties === 'function' + ? propsService.getScriptProperties() + : null + var getProp = function (key) { + return scriptProps && typeof scriptProps.getProperty === 'function' + ? scriptProps.getProperty(key) + : null + } -function decodeBase64Content(encodedContent) { - return Utilities.newBlob( - Utilities.base64Decode(encodedContent) - ).getDataAsString() -} + var cfgOwner = '' + var cfgName = '' + try { + if (typeof getAiClassifierConfig === 'function') { + var cfg = getAiClassifierConfig() + cfgOwner = (cfg && cfg.githubRepoOwner) || '' + cfgName = (cfg && cfg.githubRepoName) || '' + } + } catch (e) { + // Ignore config resolution errors in restricted scopes + } -function encodeBase64Content(content) { - return Utilities.base64Encode(content) + var envOwner = + typeof process !== 'undefined' && + process && + process.env && + process.env.GITHUB_REPO_OWNER + ? process.env.GITHUB_REPO_OWNER + : '' + var envName = + typeof process !== 'undefined' && + process && + process.env && + process.env.GITHUB_REPO_NAME + ? process.env.GITHUB_REPO_NAME + : '' + + var repoOwner = + (services && services.githubRepoOwner) || + getProp('GITHUB_REPO_OWNER') || + cfgOwner || + envOwner || + '' + var repoName = + (services && services.githubRepoName) || + getProp('GITHUB_REPO_NAME') || + cfgName || + envName || + '' + + return { repoOwner: repoOwner.trim(), repoName: repoName.trim() } } /** - * Appends a Progressive Disclosure entry to a target markdown file in self-private via GitHub REST API. + * Appends a Progressive Disclosure entry to a target markdown file via GitHub REST API. * Automatically creates the file with valid front-matter if it does not exist yet (HTTP 404). */ -function appendMarkdownEntryToGitHubRepo(filePath, entryMd, commitMessage) { - var githubToken = getGitHubToken_() +function appendMarkdownEntryToGitHubRepo( + filePath, + entryMd, + commitMessage, + services +) { + var props = + (services && services.PropertiesService) || + (typeof PropertiesService !== 'undefined' ? PropertiesService : null) + var scriptProps = + props && typeof props.getScriptProperties === 'function' + ? props.getScriptProperties() + : null + var githubToken = + scriptProps && typeof scriptProps.getProperty === 'function' + ? scriptProps.getProperty('GITHUB_PAT') + : null if (!githubToken) { console.log( '[gitHubSync] GITHUB_PAT ScriptProperty not set. Skipping GitHub commit.' @@ -64,13 +144,26 @@ function appendMarkdownEntryToGitHubRepo(filePath, entryMd, commitMessage) { return false } + var sleepFn = + (services && services.sleepFn) || + function (ms) { + if ( + typeof Utilities !== 'undefined' && + Utilities && + typeof Utilities.sleep === 'function' + ) { + Utilities.sleep(ms) + } + } + var maxRetries = 3 for (var attempt = 1; attempt <= maxRetries; attempt++) { var result = executeGitHubCommit( filePath, entryMd, commitMessage, - githubToken + githubToken, + services ) if (result === true || result === 'IDEMPOTENT_SKIP') { return true @@ -83,7 +176,7 @@ function appendMarkdownEntryToGitHubRepo(filePath, entryMd, commitMessage) { 'for', filePath ) - Utilities.sleep(1000 * attempt) + sleepFn(1000 * attempt) } console.error( @@ -98,9 +191,51 @@ function appendMarkdownEntryToGitHubRepo(filePath, entryMd, commitMessage) { /** * Performs a single commit transaction via GitHub REST API with 404 Auto-Create & 409 SHA retry handling. */ -function executeGitHubCommit(filePath, entryMd, commitMessage, githubToken) { - var url = getGitHubApiUrl_(filePath) - var headers = getGitHubHeaders_(githubToken) +function executeGitHubCommit( + filePath, + entryMd, + commitMessage, + githubToken, + services +) { + if (!filePath || !entryMd) { + console.warn('[gitHubSync] Missing filePath or entryMd for GitHub commit.') + return false + } + + var target = resolveGitHubRepoTarget_(services) + if (!target.repoOwner || !target.repoName) { + console.warn( + '[gitHubSync] GITHUB_REPO_OWNER or GITHUB_REPO_NAME not configured. Skipping GitHub commit.' + ) + return false + } + + var urlFetchApp = + (services && services.UrlFetchApp) || + (typeof UrlFetchApp !== 'undefined' ? UrlFetchApp : null) + var utils = + (services && services.Utilities) || + (typeof Utilities !== 'undefined' ? Utilities : null) + + var encodedPath = String(filePath) + .split('/') + .filter(Boolean) + .map(encodeURIComponent) + .join('/') + var url = + 'https://api.github.com/repos/' + + encodeURIComponent(target.repoOwner) + + '/' + + encodeURIComponent(target.repoName) + + '/contents/' + + encodedPath + var headers = { + Authorization: 'Bearer ' + githubToken, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'Google-Apps-Script', + } try { var getOptions = { @@ -108,7 +243,7 @@ function executeGitHubCommit(filePath, entryMd, commitMessage, githubToken) { headers: headers, muteHttpExceptions: true, } - var res = UrlFetchApp.fetch(url, getOptions) + var res = urlFetchApp.fetch(url, getOptions) var statusCode = res.getResponseCode() var sha = null @@ -121,12 +256,16 @@ function executeGitHubCommit(filePath, entryMd, commitMessage, githubToken) { filePath ) var topicTitle = extractTopicTitleFromPath(filePath) + var dateStr = + utils && typeof utils.formatDate === 'function' + ? utils.formatDate(new Date(), 'GMT', 'yyyy-MM-dd') + : new Date().toISOString().slice(0, 10) rawContent = '---\ntitle: ' + topicTitle + '\ncreated: ' + - Utilities.formatDate(new Date(), 'GMT', 'yyyy-MM-dd') + - '\nnotebook: household-vault\nsection: general\n---\n\n' + + dateStr + + '\nnotebook: household\nsection: general\n---\n\n' + '# ' + topicTitle + '\n\n' + @@ -136,7 +275,13 @@ function executeGitHubCommit(filePath, entryMd, commitMessage, githubToken) { } else if (statusCode === 200) { var fileData = JSON.parse(res.getContentText()) sha = fileData.sha - rawContent = decodeBase64Content(fileData.content) + // GitHub Contents API wraps Base64 in newlines every 60 chars; strip them + // before decoding since some decoders reject embedded whitespace. + rawContent = utils + .newBlob( + utils.base64Decode((fileData.content || '').replace(/[\r\n]/g, '')) + ) + .getDataAsString() // Idempotency Check: Skip if entry already present if ( @@ -161,7 +306,21 @@ function executeGitHubCommit(filePath, entryMd, commitMessage, githubToken) { // 2. Section-Aware Insertion var updatedContent = insertEntryIntoLogSection(rawContent, entryMd) - var base64Updated = encodeBase64Content(updatedContent) + + // Rule 6 Guards: Refuse to commit if mojibake is detected in new entry or non-ASCII chars were flattened + assertClean_(entryMd, 'new entry for ' + filePath) + + var base64Updated = utils.base64Encode( + utils.newBlob(updatedContent).getBytes() + ) + + // Validate the ACTUAL Base64 round trip at the payload boundary: decode what + // we are about to PUT and confirm no non-ASCII character was flattened to + // '?'. + var renderedContent = utils + .newBlob(utils.base64Decode(base64Updated)) + .getDataAsString() + assertNoAsciiReplacement_(updatedContent, renderedContent) // 3. Commit updated content back to GitHub main branch var putPayload = { @@ -183,7 +342,7 @@ function executeGitHubCommit(filePath, entryMd, commitMessage, githubToken) { muteHttpExceptions: true, } - var putRes = UrlFetchApp.fetch(url, putOptions) + var putRes = urlFetchApp.fetch(url, putOptions) var putStatus = putRes.getResponseCode() if (putStatus === 200 || putStatus === 201) { @@ -209,14 +368,32 @@ function executeGitHubCommit(filePath, entryMd, commitMessage, githubToken) { } function extractTopicTitleFromPath(filePath) { - var parts = filePath.split('/') + if (!filePath || typeof filePath !== 'string') { + return 'Ingested Records' + } + var trimmed = filePath.trim() + if (!trimmed || trimmed === '/') { + return 'Ingested Records' + } + var parts = trimmed.split('/').filter(Boolean) + if (parts.length === 0) { + return 'Ingested Records' + } var topic = parts.length > 1 ? parts[parts.length - 2] : parts[0] - return topic.replace(/-/g, ' ').replace(/\b\w/g, function (l) { + // Strip any trailing file extension (e.g. ".md") before title-casing so a + // single-segment path like "digital-backups.md" becomes "Digital Backups". + var cleaned = topic.replace(/\.[^.]+$/, '').replace(/-/g, ' ') + if (!cleaned) return 'Ingested Records' + return cleaned.replace(/\b\w/g, function (l) { return l.toUpperCase() }) } function insertEntryIntoLogSection(fullContent, newEntry) { + if (!fullContent && !newEntry) return '' + if (!fullContent) return String(newEntry) + if (!newEntry) return String(fullContent) + var detailsMarker = '</details>' var detailsIndex = fullContent.indexOf(detailsMarker) @@ -245,236 +422,15 @@ function insertEntryIntoLogSection(fullContent, newEntry) { return fullContent + '\n' + newEntry } -/** - * 2-Way Sync Engine: Fetches rules.json from self-private via GitHub REST API. - */ -function fetchRulesFromGitHub(filePath, githubToken) { - var targetPath = - filePath || - '05_Tech_Infrastructure/gmail-cleanup-and-label-taxonomy/rules.json' - var token = getGitHubToken_(githubToken) - if (!token) { - console.log( - '[gitHubSync] GITHUB_PAT not set. Skipping fetchRulesFromGitHub.' - ) - return null - } - - var url = getGitHubApiUrl_(targetPath) - var headers = getGitHubHeaders_(token) - - try { - // NOSONAR — intentional error-handling block duplication for robustness - var res = UrlFetchApp.fetch(url, { - method: 'get', - headers: headers, - muteHttpExceptions: true, - }) - var status = res.getResponseCode() - if (status === 200) { - var data = JSON.parse(res.getContentText()) - var decoded = decodeBase64Content(data.content) - var parsed = JSON.parse(decoded) - parsed._sha = data.sha - return parsed - } else if (status !== 404) { - throw new Error( - 'GitHub API returned status ' + status + ': ' + res.getContentText() - ) - } - } catch (e) { - // NOSONAR — intentional error-handling block duplication for robustness - console.error( - '[gitHubSync] Exception fetching rules from GitHub:', - e.message - ) - throw e - } - return null -} - -/** - * 2-Way Sync Engine: Commits rules object back to self-private via GitHub REST API. - * @param {Object} rulesObj - Rules to commit (mutated in-place: _sha deleted, updatedAt set) - * @param {string} [commitMessage] - Git commit message - * @param {string} [githubToken] - GitHub PAT; falls back to script property GITHUB_PAT - * @param {string} [knownSha] - SHA of the current remote file; skips an extra GET when provided - */ -function commitRulesToGitHub(rulesObj, commitMessage, githubToken, knownSha) { - var targetPath = - '05_Tech_Infrastructure/gmail-cleanup-and-label-taxonomy/rules.json' - var token = getGitHubToken_(githubToken) - if (!token) { - console.log( - '[gitHubSync] GITHUB_PAT not set. Skipping commitRulesToGitHub.' - ) - return false - } - - var sha = knownSha - if (!sha) { - try { - var currentRemote = fetchRulesFromGitHub(targetPath, token) - sha = currentRemote ? currentRemote._sha : null - } catch (e) { - console.error('[gitHubSync] Failed to fetch SHA for commit:', e.message) - return false - } - } - delete rulesObj._sha - rulesObj.updatedAt = Utilities.formatDate( - new Date(), - 'GMT', - "yyyy-MM-dd'T'HH:mm:ss'Z'" - ) - - var rawJson = JSON.stringify(rulesObj, null, 2) - var base64Content = encodeBase64Content(rawJson) - - var url = getGitHubApiUrl_(targetPath) - var headers = getGitHubHeaders_(token) - var commitMsg = - commitMessage || - 'feat(taxonomy): update rules.json via 2-way Apps Script sync engine' - - function buildPayload(currentSha) { - var p = { message: commitMsg, content: base64Content, branch: 'main' } - if (currentSha) p.sha = currentSha - return p - } - - function executePut_(shaToUse) { - return UrlFetchApp.fetch(url, { - method: 'put', - headers: headers, - contentType: 'application/json', - payload: JSON.stringify(buildPayload(shaToUse)), - muteHttpExceptions: true, - }) - } - - try { - // NOSONAR — intentional error-handling block duplication for robustness - var putRes = executePut_(sha) - var status = putRes.getResponseCode() - if (status === 200 || status === 201) return true - if (status === 409) { - // SHA conflict: refresh and retry once - var refreshed = fetchRulesFromGitHub(targetPath, token) - var freshSha = refreshed ? refreshed._sha : null - var retryRes = executePut_(freshSha) - var retryStatus = retryRes.getResponseCode() - return retryStatus === 200 || retryStatus === 201 - } - console.error( - '[gitHubSync] Unexpected PUT status:', - status, - putRes.getContentText() - ) - return false - } catch (e) { - // NOSONAR — intentional error-handling block duplication for robustness - console.error( - '[gitHubSync] Exception committing rules to GitHub:', - e.message - ) - return false - } -} - -/** - * Perform bi-directional 2-Way Synchronization between GAS Script Properties & GitHub rules.json. - */ -function syncTwoWayRules() { - var props = PropertiesService.getScriptProperties() - - var remoteRules - try { - remoteRules = fetchRulesFromGitHub() - } catch (e) { - console.error('[gitHubSync] Failed to fetch remote rules:', e.message) - return false - } - if (!remoteRules) return false - - var localJsonStr = props.getProperty('CLASSIFICATION_RULES_JSON') - if (!localJsonStr) { - // Initial GAS setup: Store remote rules locally - props.setProperty('CLASSIFICATION_RULES_JSON', JSON.stringify(remoteRules)) - console.log('[gitHubSync] Local rules initialized from GitHub rules.json.') - return true - } - - var localRules - try { - localRules = JSON.parse(localJsonStr) - } catch (e) { - console.error( - '[gitHubSync] Invalid local rules JSON, skipping sync:', - e.message - ) - return false - } - - var remoteTs = new Date(remoteRules.updatedAt).getTime() - var localTs = new Date(localRules.updatedAt).getTime() - var remoteDate = isNaN(remoteTs) ? 0 : remoteTs - var localDate = isNaN(localTs) ? 0 : localTs - - if (remoteDate > localDate) { - // GitHub rules are newer -> Update GAS Script Property - props.setProperty('CLASSIFICATION_RULES_JSON', JSON.stringify(remoteRules)) - console.log( - '[gitHubSync] Pulled newer rules from GitHub into GAS Script Properties.' - ) - return true - } else if (localDate > remoteDate) { - // GAS rules were tuned online -> Push back to GitHub - var success = commitRulesToGitHub( - localRules, - 'feat(taxonomy): push live Apps Script rule tuning to GitHub rules.json', - null, - remoteRules._sha - ) - if (success) - console.log( - '[gitHubSync] Pushed live GAS rule tuning to GitHub rules.json.' - ) - return success - } - - // Equal timestamps: use content comparison as tie-breaker (exclude internal _sha field) - var remoteForCompare = JSON.parse(JSON.stringify(remoteRules)) - delete remoteForCompare._sha - var localForCompare = JSON.parse(JSON.stringify(localRules)) - delete localForCompare._sha - if (JSON.stringify(remoteForCompare) !== JSON.stringify(localForCompare)) { - props.setProperty('CLASSIFICATION_RULES_JSON', JSON.stringify(remoteRules)) - console.log( - '[gitHubSync] Equal timestamps but content differed — pulled remote as canonical.' - ) - return true - } - - console.log('[gitHubSync] 2-Way Rules Sync is up-to-date.') - return true -} - -// NOSONAR — Jest interop guard for test environment, not executed in GAS runtime -/* istanbul ignore if */ if (typeof module !== 'undefined' && module.exports) { module.exports = { appendMarkdownEntryToGitHubRepo: appendMarkdownEntryToGitHubRepo, executeGitHubCommit: executeGitHubCommit, extractTopicTitleFromPath: extractTopicTitleFromPath, insertEntryIntoLogSection: insertEntryIntoLogSection, - fetchRulesFromGitHub: fetchRulesFromGitHub, - commitRulesToGitHub: commitRulesToGitHub, - syncTwoWayRules: syncTwoWayRules, - getGitHubApiUrl_: getGitHubApiUrl_, - getGitHubHeaders_: getGitHubHeaders_, - getGitHubToken_: getGitHubToken_, - decodeBase64Content: decodeBase64Content, - encodeBase64Content: encodeBase64Content, + resolveGitHubRepoTarget_: resolveGitHubRepoTarget_, + assertClean_: assertClean_, + assertNoAsciiReplacement_: assertNoAsciiReplacement_, + RULE6_PATTERNS: GITHUB_SYNC_RULE6_PATTERNS_, } } diff --git a/src/gmail-ai-classifier/src/github-sync.js b/src/gmail-ai-classifier/src/github-sync.js deleted file mode 100644 index 282fb317..00000000 --- a/src/gmail-ai-classifier/src/github-sync.js +++ /dev/null @@ -1,531 +0,0 @@ -/** - * Native Google Apps Script GitHub Sync Module (Auto-Create & Long Document Aware) - * Calls GitHub REST API via UrlFetchApp to commit Markdown entries directly to self-private repo. - * - * Features: - * - Auto-creates missing target markdown files on GitHub (HTTP 404 handling) - * - SHA Collision & Retry Guard (catches HTTP 409 API conflicts & retries) - * - Section-Aware Targeted Insertion (inserts entries into Section 3 log blocks) - * - Idempotency Guard (skips duplicate entries) - * NOSONAR — contains multiple similar error-handling blocks across API operations, intentional for robustness - */ - -var GITHUB_REPO_OWNER = 'user-org' -var GITHUB_REPO_NAME = 'self-private' - -function getGitHubApiUrl_(filePath) { - return ( - 'https://api.github.com/repos/' + - GITHUB_REPO_OWNER + - '/' + - GITHUB_REPO_NAME + - '/contents/' + - filePath - ) -} - -function getGitHubHeaders_(token) { - return { - Authorization: 'token ' + token, - Accept: 'application/vnd.github.v3+json', - 'User-Agent': 'Google-Apps-Script', - } -} - -function getGitHubToken_(overrideToken) { - return ( - overrideToken || - PropertiesService.getScriptProperties().getProperty('GITHUB_PAT') - ) -} - -function decodeBase64Content(encodedContent) { - return Utilities.newBlob( - Utilities.base64Decode(encodedContent) - ).getDataAsString() -} - -function encodeBase64Content(content) { - return Utilities.base64Encode(content) -} - -/** - * Appends a Progressive Disclosure entry to a target markdown file in self-private via GitHub REST API. - * Automatically creates the file with valid front-matter if it does not exist yet (HTTP 404). - */ -function appendMarkdownEntryToGitHubRepo(filePath, entryMd, commitMessage) { - var githubToken = getGitHubToken_() - if (!githubToken) { - console.log( - '[gitHubSync] GITHUB_PAT ScriptProperty not set. Skipping GitHub commit.' - ) - return false - } - - var maxRetries = 3 - for (var attempt = 1; attempt <= maxRetries; attempt++) { - var result = executeGitHubCommit( - filePath, - entryMd, - commitMessage, - githubToken - ) - if (result === true) { - return true - } - console.log( - '[gitHubSync] Retry attempt', - attempt, - 'of', - maxRetries, - 'for', - filePath - ) - Utilities.sleep(1000 * attempt) - } - - console.error( - '[gitHubSync] Failed to commit entry to GitHub after', - maxRetries, - 'attempts:', - filePath - ) - return false -} - -/** - * Performs a single commit transaction via GitHub REST API with 404 Auto-Create & 409 SHA retry handling. - */ -function executeGitHubCommit(filePath, entryMd, commitMessage, githubToken) { - var url = getGitHubApiUrl_(filePath) - var headers = getGitHubHeaders_(githubToken) - - try { - var getOptions = { - method: 'get', - headers: headers, - muteHttpExceptions: true, - } - var res = UrlFetchApp.fetch(url, getOptions) - var statusCode = res.getResponseCode() - - var sha = null - var rawContent = '' - - if (statusCode === 404) { - // 1. File does not exist on GitHub -> Initialize with standard Front-Matter & 3-Layer Template - console.log( - '[gitHubSync] File not found on GitHub (HTTP 404). Initializing new note:', - filePath - ) - var topicTitle = extractTopicTitleFromPath(filePath) - rawContent = - '---\ntitle: ' + - topicTitle + - '\ncreated: ' + - Utilities.formatDate(new Date(), 'GMT', 'yyyy-MM-dd') + - '\nnotebook: household-vault\nsection: general\n---\n\n' + - '# ' + - topicTitle + - '\n\n' + - '## 1. Executive Summary & Active Status\n- Ingested records log.\n\n' + - '## 2. Key References & Quick Links\n| Topic | Asset |\n| :--- | :--- |\n\n' + - '## 3. Ingested Activity Logs\n<details open><summary><b>Activity Logs</b></summary>\n</details>\n' - } else if (statusCode === 200) { - var fileData = JSON.parse(res.getContentText()) - sha = fileData.sha - rawContent = decodeBase64Content(fileData.content) - - // Idempotency Check: Skip if entry already present - if (rawContent.includes(entryMd.trim())) { - console.log( - '[gitHubSync] Idempotent Skip: Entry already exists in', - filePath - ) - return true - } - } else { - console.error( - '[gitHubSync] Error fetching file from GitHub (HTTP ' + - statusCode + - '):', - res.getContentText() - ) - return false - } - - // 2. Section-Aware Insertion - var updatedContent = insertEntryIntoLogSection(rawContent, entryMd) - var base64Updated = encodeBase64Content(updatedContent) - - // 3. Commit updated content back to GitHub main branch - var putPayload = { - message: - commitMessage || - 'feat(ingestion): append ingested document entry via Google Apps Script', - content: base64Updated, - branch: 'main', - } - if (sha) { - putPayload.sha = sha - } - - var putOptions = { - method: 'put', - headers: headers, - contentType: 'application/json', - payload: JSON.stringify(putPayload), - muteHttpExceptions: true, - } - - var putRes = UrlFetchApp.fetch(url, putOptions) - var putStatus = putRes.getResponseCode() - - if (putStatus === 200 || putStatus === 201) { - console.log( - '[gitHubSync] Successfully committed markdown entry to GitHub:', - filePath - ) - return true - } else if (putStatus === 409) { - console.warn('[gitHubSync] SHA collision (HTTP 409) on file:', filePath) - return false - } else { - console.error( - '[gitHubSync] Error committing to GitHub (HTTP ' + putStatus + '):', - putRes.getContentText() - ) - return false - } - } catch (e) { - console.error('[gitHubSync] Exception calling GitHub API:', e.message) - return false - } -} - -function extractTopicTitleFromPath(filePath) { - var parts = filePath.split('/') - var topic = parts.length > 1 ? parts[parts.length - 2] : parts[0] - return topic.replaceAll('-', ' ').replace(/\b\w/g, function (l) { - return l.toUpperCase() - }) -} - -function insertEntryIntoLogSection(fullContent, newEntry) { - var detailsMarker = '</details>' - var detailsIndex = fullContent.indexOf(detailsMarker) - - if (detailsIndex !== -1) { - return ( - fullContent.substring(0, detailsIndex) + - newEntry + - '\n' + - fullContent.substring(detailsIndex) - ) - } - - var section3Marker = '## 3. Ingested Activity' - var section3Index = fullContent.indexOf(section3Marker) - - if (section3Index !== -1) { - var lineBreakIndex = fullContent.indexOf('\n', section3Index) - return ( - fullContent.substring(0, lineBreakIndex + 1) + - newEntry + - '\n' + - fullContent.substring(lineBreakIndex + 1) - ) - } - - return fullContent + '\n' + newEntry -} - -/** - * 2-Way Sync Engine: Fetches rules.json from self-private via GitHub REST API. - */ -function fetchRulesFromGitHub(filePath, githubToken) { - var targetPath = - filePath || - '05_Tech_Infrastructure/gmail-cleanup-and-label-taxonomy/rules.json' - var token = getGitHubToken_(githubToken) - if (!token) { - console.log( - '[gitHubSync] GITHUB_PAT not set. Skipping fetchRulesFromGitHub.' - ) - return null - } - - var url = getGitHubApiUrl_(targetPath) - var headers = getGitHubHeaders_(token) - - try { - // NOSONAR — intentional error-handling block duplication for robustness - var res = UrlFetchApp.fetch(url, { - method: 'get', - headers: headers, - muteHttpExceptions: true, - }) - var status = res.getResponseCode() - if (status === 200) { - var data = JSON.parse(res.getContentText()) - var decoded = decodeBase64Content(data.content) - var parsed = JSON.parse(decoded) - parsed._sha = data.sha - return parsed - } else if (status !== 404) { - throw new Error( - 'GitHub API returned status ' + status + ': ' + res.getContentText() - ) - } - } catch (e) { - // NOSONAR — intentional error-handling block duplication for robustness - console.error( - '[gitHubSync] Exception fetching rules from GitHub:', - e.message - ) - throw e - } - return null -} - -/** - * Compares two rules objects for content equality, ignoring the internal _sha field. - */ -function rulesContentEquals_(a, b) { - var aCopy = { ...a } - delete aCopy._sha - var bCopy = { ...b } - delete bCopy._sha - return JSON.stringify(aCopy) === JSON.stringify(bCopy) -} - -/** - * Decides whether it is safe to overwrite the freshly-fetched remote rules with the - * local copy after a 409 conflict. - * - * Timestamps are parsed to epoch millis (normalizing timezone offsets, e.g. a - * `+00:00` vs `Z` suffix, to the same instant) rather than compared as strings. - * When either timestamp is missing/invalid or the two are equal, we cannot rank - * them by time, so we fall back to a content comparison: if the remote already - * differs from our local copy we abort rather than clobber a possibly-newer remote. - * - * @param {Object} remoteRules - Freshly fetched remote rules (may include _sha) - * @param {Object} localRules - Local rules about to be committed - * @param {string} localUpdatedAt - Local rules' updatedAt timestamp - * @returns {boolean} true if overwriting the remote is safe, false to abort - */ -function isSafeToOverwriteRemote_(remoteRules, localRules, localUpdatedAt) { - var remoteMs = new Date(remoteRules.updatedAt).getTime() - var localMs = new Date(localUpdatedAt).getTime() - var timestampsComparable = - !Number.isNaN(remoteMs) && !Number.isNaN(localMs) && remoteMs !== localMs - - if (timestampsComparable) { - if (remoteMs > localMs) { - console.error('[gitHubSync] Remote copy is newer; aborting commit') - return false - } - return true - } - - // Timestamps are missing, invalid, or represent the same instant: only overwrite - // when the remote content matches what we already have. - if (!rulesContentEquals_(remoteRules, localRules)) { - console.error( - '[gitHubSync] Timestamps inconclusive and remote content differs; aborting commit' - ) - return false - } - return true -} - -/** - * 2-Way Sync Engine: Commits rules object back to self-private via GitHub REST API. - * @param {Object} rulesObj - Rules to commit (mutated in-place: _sha deleted, updatedAt set) - * @param {string} [commitMessage] - Git commit message - * @param {string} [githubToken] - GitHub PAT; falls back to script property GITHUB_PAT - * @param {string} [knownSha] - SHA of the current remote file; skips an extra GET when provided - */ -function commitRulesToGitHub(rulesObj, commitMessage, githubToken, knownSha) { - var targetPath = - '05_Tech_Infrastructure/gmail-cleanup-and-label-taxonomy/rules.json' - var token = getGitHubToken_(githubToken) - if (!token) { - console.log( - '[gitHubSync] GITHUB_PAT not set. Skipping commitRulesToGitHub.' - ) - return false - } - - var sha = knownSha - if (!sha) { - try { - var currentRemote = fetchRulesFromGitHub(targetPath, token) - sha = currentRemote ? currentRemote._sha : null - } catch (e) { - console.error('[gitHubSync] Failed to fetch SHA for commit:', e.message) - return false - } - } - delete rulesObj._sha - rulesObj.updatedAt = Utilities.formatDate( - new Date(), - 'GMT', - "yyyy-MM-dd'T'HH:mm:ss'Z'" - ) - - var localUpdatedAt = rulesObj.updatedAt - var rawJson = JSON.stringify(rulesObj, null, 2) - var base64Content = encodeBase64Content(rawJson) - - var url = getGitHubApiUrl_(targetPath) - var headers = getGitHubHeaders_(token) - var commitMsg = - commitMessage || - 'feat(taxonomy): update rules.json via 2-way Apps Script sync engine' - - function buildPayload(currentSha) { - var p = { message: commitMsg, content: base64Content, branch: 'main' } - if (currentSha) p.sha = currentSha - return p - } - - function executePut_(shaToUse) { - return UrlFetchApp.fetch(url, { - method: 'put', - headers: headers, - contentType: 'application/json', - payload: JSON.stringify(buildPayload(shaToUse)), - muteHttpExceptions: true, - }) - } - - try { - // NOSONAR — intentional error-handling block duplication for robustness - var putRes = executePut_(sha) - var status = putRes.getResponseCode() - if (status === 200 || status === 201) return true - if (status === 409) { - // SHA conflict: refresh and decide whether it is safe to overwrite remote. - var refreshed = fetchRulesFromGitHub(targetPath, token) - if ( - refreshed && - !isSafeToOverwriteRemote_(refreshed, rulesObj, localUpdatedAt) - ) { - return false - } - var freshSha = refreshed ? refreshed._sha : null - var retryRes = executePut_(freshSha) - var retryStatus = retryRes.getResponseCode() - return retryStatus === 200 || retryStatus === 201 - } - console.error( - '[gitHubSync] Unexpected PUT status:', - status, - putRes.getContentText() - ) - return false - } catch (e) { - // NOSONAR — intentional error-handling block duplication for robustness - console.error( - '[gitHubSync] Exception committing rules to GitHub:', - e.message - ) - return false - } -} - -/** - * Perform bi-directional 2-Way Synchronization between GAS Script Properties & GitHub rules.json. - */ -function syncTwoWayRules() { - var props = PropertiesService.getScriptProperties() - - var remoteRules - try { - remoteRules = fetchRulesFromGitHub() - } catch (e) { - console.error('[gitHubSync] Failed to fetch remote rules:', e.message) - return false - } - if (!remoteRules) return false - - var localJsonStr = props.getProperty('CLASSIFICATION_RULES_JSON') - if (!localJsonStr) { - // Initial GAS setup: Store remote rules locally - props.setProperty('CLASSIFICATION_RULES_JSON', JSON.stringify(remoteRules)) - console.log('[gitHubSync] Local rules initialized from GitHub rules.json.') - return true - } - - var localRules - try { - localRules = JSON.parse(localJsonStr) - } catch (e) { - console.error( - '[gitHubSync] Invalid local rules JSON, skipping sync:', - e.message - ) - return false - } - - var remoteTs = new Date(remoteRules.updatedAt).getTime() - var localTs = new Date(localRules.updatedAt).getTime() - var remoteDate = Number.isNaN(remoteTs) ? 0 : remoteTs - var localDate = Number.isNaN(localTs) ? 0 : localTs - - if (remoteDate > localDate) { - // GitHub rules are newer -> Update GAS Script Property - props.setProperty('CLASSIFICATION_RULES_JSON', JSON.stringify(remoteRules)) - console.log( - '[gitHubSync] Pulled newer rules from GitHub into GAS Script Properties.' - ) - return true - } else if (localDate > remoteDate) { - // GAS rules were tuned online -> Push back to GitHub - var success = commitRulesToGitHub( - localRules, - 'feat(taxonomy): push live Apps Script rule tuning to GitHub rules.json', - null, - remoteRules._sha - ) - if (success) - console.log( - '[gitHubSync] Pushed live GAS rule tuning to GitHub rules.json.' - ) - return success - } - - // Equal timestamps: use content comparison as tie-breaker (exclude internal _sha field) - if (!rulesContentEquals_(remoteRules, localRules)) { - props.setProperty('CLASSIFICATION_RULES_JSON', JSON.stringify(remoteRules)) - console.log( - '[gitHubSync] Equal timestamps but content differed — pulled remote as canonical.' - ) - return true - } - - console.log('[gitHubSync] 2-Way Rules Sync is up-to-date.') - return true -} - -/* sonar.javascript.skipCoverage */ -/* istanbul ignore if */ -if (typeof module !== 'undefined' && module.exports) { - module.exports = { - appendMarkdownEntryToGitHubRepo: appendMarkdownEntryToGitHubRepo, - executeGitHubCommit: executeGitHubCommit, - extractTopicTitleFromPath: extractTopicTitleFromPath, - insertEntryIntoLogSection: insertEntryIntoLogSection, - fetchRulesFromGitHub: fetchRulesFromGitHub, - rulesContentEquals_: rulesContentEquals_, - isSafeToOverwriteRemote_: isSafeToOverwriteRemote_, - commitRulesToGitHub: commitRulesToGitHub, - syncTwoWayRules: syncTwoWayRules, - getGitHubApiUrl_: getGitHubApiUrl_, - getGitHubHeaders_: getGitHubHeaders_, - getGitHubToken_: getGitHubToken_, - decodeBase64Content: decodeBase64Content, - encodeBase64Content: encodeBase64Content, - } -} diff --git a/src/gmail-ai-classifier/src/index.js b/src/gmail-ai-classifier/src/index.js index b426bfcf..5c3d9dba 100644 --- a/src/gmail-ai-classifier/src/index.js +++ b/src/gmail-ai-classifier/src/index.js @@ -4,6 +4,21 @@ * with Jest without a live Google Apps Script environment. */ +const { getFileHash: gasGetFileHash } = require('../../gas-utils') +const { getAiClassifierConfig, getClassifierConfig } = require('../config.gs') + +/** + * Computes MD5 hex digest for a GAS blob safely without throwing for invalid blobs. + */ +function getFileHash(blob) { + if (!blob) return '' + try { + return gasGetFileHash(blob) + } catch { + return '' + } +} + /** * Validates a Gemini classification response object. * @@ -37,10 +52,179 @@ function validateClassification(classification, canonicalDomains) { return true } +const VALID_ONTOLOGICAL_CATEGORIES_ = [ + 'Primary', + 'Updates', + 'Promotions', + 'Social', + 'Forums', +] +const VALID_ONTOLOGICAL_ACTIONS_ = ['keep', 'archive', 'trash', 'mark_read'] + +/** + * Validates an ontological classification response object returned by classifyWithGemini. + * + * @param {Object} classification - Parsed response from Gemini + * @param {string[]} [canonicalDomains] - Allowed canonical domain keys + * @returns {boolean} + */ +function validateOntologicalClassification(classification, canonicalDomains) { + if ( + !classification || + typeof classification !== 'object' || + Array.isArray(classification) + ) { + return false + } + if ( + typeof classification.confidence === 'number' && + (!Number.isFinite(classification.confidence) || + classification.confidence < 0 || + classification.confidence > 1) + ) { + return false + } + if ( + classification.confidence !== undefined && + typeof classification.confidence !== 'number' + ) { + return false + } + var domain = classification.canonicalDomain + if (domain !== null && domain !== undefined && domain !== '') { + if (typeof domain !== 'string') return false + if ( + Array.isArray(canonicalDomains) && + canonicalDomains.length > 0 && + canonicalDomains.indexOf(domain) === -1 + ) { + return false + } + } + if ( + classification.category !== undefined && + classification.category !== null && + VALID_ONTOLOGICAL_CATEGORIES_.indexOf(classification.category) === -1 + ) { + return false + } + if ( + classification.action !== undefined && + classification.action !== null && + VALID_ONTOLOGICAL_ACTIONS_.indexOf(classification.action) === -1 + ) { + return false + } + return true +} + function _sleep(ms) { if (typeof Utilities !== 'undefined') Utilities.sleep(ms) } +/** + * Constructs an Ontological Knowledge Graph & Triage Matrix prompt for Gemini classification. + * Uses positive invariant domain scopes, an orthogonal lifecycle triage matrix, + * and user entity graph injection, eliminating negative exclusion rules. + * + * @param {Object} config - Classifier configuration (canonicalDomains, customPromptRules) + * @param {string} sender - Email sender header + * @param {string} subject - Email subject line + * @param {string} snippet - Email body snippet + * @returns {string} Prompt text for Gemini API + */ +function buildOntologicalPrompt(config, sender, subject, snippet) { + const domains = (config && config.canonicalDomains) || [] + let prompt = + 'You are an executive email classifier. Perform semantic classification into ONE canonical domain key from: ' + + JSON.stringify(domains) + + ' (or null if non-canonical).\n\n' + + '=== TIER 1: DOMAIN TAXONOMY ONTOLOGY (POSITIVE INVARIANTS) ===\n' + + 'Classify emails based on what each domain positively governs:\n' + + "• '01_Household': Physical residence, real estate property, maintenance, home repairs, contractor invoices, home utilities, household inventory, and travel/lodging reservations. Valid sub-labels: 'Household/Property', 'Household/Maintenance', 'Household/Travel'.\n" + + "• '02_Finance_Legal': Personal banking, checking/savings, credit cards, investments, mortgages, personal tax filings (W-2, 1098, 1099, returns), utility payment accounts/funding, purchase invoices/receipts, health/auto/property insurance policies and explanations of benefits (EOB), actual personal legal proceedings, court orders, attorney correspondence, dispute filings, and executed personal contracts (leases, deeds, wills, trusts, powers of attorney). (Excludes commercial terms of service updates, which are non-canonical broadcast notices). Valid sub-labels: 'Finance/Banking', 'Finance/Bills', 'Finance/Purchases', 'Finance/Insurance', 'Finance/Taxes', 'Finance/Donations', 'Finance/Charitable-Donations', 'Finance/Legal'.\n" + + "• '03_Vehicles': Personal automobile titles, registrations, vehicle insurance, automotive maintenance, repairs, parts, and car rental reservations. Valid sub-labels: 'Vehicles/Maintenance', 'Vehicles/Purchases', 'Vehicles/Rental-Cars'.\n" + + "• '04_Family_Health': Family correspondence, healthcare records, doctor appointments, patient portals, prescriptions, elder care, and student education/coursework/school portals. Valid sub-labels: 'Family/Kids/Tide', 'Family/Kids/Toby', 'Family/Kids/David', 'Family/School-Student', 'Family/Medical', 'Family/Legal', 'Family/Correspondence', 'Family/Parents', 'Family/Sisters'.\n" + + "• '05_Tech_Infrastructure': Cloud hosting, server infrastructure, domains/DNS, network hardware, security alerts, system telemetry, and developer platform quota/outage alerts. Valid sub-labels: 'Tech/Cloud', 'Tech/Security', 'Tech/Alerts'.\n" + + "• '06_Work_Career': Professional employment, career advancement, job applications, recruiter correspondence, interview schedules, employer benefits, and consulting. Valid sub-labels: 'Work/Career', 'Work/Employer'.\n" + + "• '07_Community_NonProfit': Official 501(c)(3) charities, non-profit boards of directors, volunteer shift schedules, civic records, community/apiary telemetry, and non-profit initiatives. Valid sub-labels: 'Projects/Charity', 'Community/BOD', 'Projects/Telemetry', 'Projects/Beekeeping', 'Projects/HOG'.\n\n" + + 'NON-CANONICAL EMAILS (canonicalDomain: null):\n' + + '• Media, platform, and enterprise daily/weekly activity digests and newsletters (Substack, Medium, LinkedIn digests, corporate/enterprise platform activity digests, daily digest emails, news recaps, blogs, trade publications) -> canonicalDomain: null, subLabel: null, category: "Updates" (or "Promotions"), action: "archive".\n' + + '• Retail marketing, store discounts, commercial coupons, equipment supply store sales, and e-commerce promotional blasts (e.g. retail equipment/tools/bee supply discounts, store newsletters, commercial promotional sales) -> canonicalDomain: null, subLabel: null, category: "Promotions", action: "archive". (Distinguish commercial store sales from actual community/apiary telemetry or non-profit initiatives).\n' + + '• Customer satisfaction surveys, Net Promoter Score (NPS) ratings, support feedback forms, and service questionnaires (e.g. airline/travel surveys, cloud/developer platform feedback surveys, camp/program feedback ratings, vendor review requests) -> canonicalDomain: null, subLabel: null, category: "Promotions" (or "Updates"), action: "archive".\n' + + '• Political campaign communications, candidate fundraising appeals, PAC broadcasts, election solicitations, and news subscription pitches (e.g. campaign fundraising appeals, news subscription drives, daily news digests) -> canonicalDomain: null, subLabel: null, category: "Promotions" (or "Updates"), action: "archive".\n' + + '• Commercial webinars, product demos, vendor marketing broadcasts.\n' + + '• Unsolicited real estate cold calls, off-market wholesaler pitches, bulk solicitation.\n' + + '• Commercial Terms of Service (TOS) updates, privacy policy revisions, arbitration updates, and platform terms/agreements (e.g. Waymo, Google, Uber, Apple, bank policy updates) -> non-canonical broadcast notices; route to category "Promotions" (or "Updates") with action "archive".\n\n' + + 'AUTOMATED SEARCH & MONITORING ALERTS (Google Alerts, Talkwalker, CourtListener, web mentions):\n' + + 'Route monitoring alerts strictly according to the subject entity being monitored:\n' + + "• Person, family member, elder care, or genealogy monitoring -> '04_Family_Health' ('Family/Legal' or 'Family/Correspondence') or '02_Finance_Legal' ('Finance/Legal').\n" + + "• Municipal, neighborhood, zoning, or real property monitoring -> '01_Household' ('Household/Property').\n" + + "• Corporate, business, industry, or career monitoring -> '06_Work_Career' ('Work/Career').\n" + + "• General news or unassigned media mention -> canonicalDomain: null (category: 'Updates', action: 'archive').\n" + + '• Student or school sub-labels apply only when the monitored alert query specifically targets an academic program or school.\n\n' + + 'AUTOMATED MACHINE & SENSOR TELEMETRY (BroodMinder, weather stations, IoT sensors, server metrics, device status pings, cron logs, uptime monitors):\n' + + '• Community/beehive telemetry (BroodMinder monitors, "Events in your apiaries", hive alerts) -> "07_Community_NonProfit", sub-label "Projects/Telemetry" or "Projects/Beekeeping".\n' + + '• Tech infrastructure/server telemetry (device pings, server metrics, cron logs) -> "05_Tech_Infrastructure", sub-label "Tech/Alerts".\n' + + '• MANDATORY ROUTING: Always route machine telemetry to category "Updates" with action "archive". Keep telemetry completely archived out of the Inbox.\n\n' + + 'SCHOOL & STUDENT ANNOUNCEMENTS VS DIRECT CORRESPONDENCE (MCAA, Briarwood, FACTS, PowerSchool, ParentSquare, Canvas, Google Classroom, Colleges):\n' + + '• Routine school announcements, district/ParentSquare broadcasts, headmaster/principal letters, athletics/arts/club notices (e.g. "Noteworthy News", "Athletics Newsletter", "Lion Guard Update"), lunch menus, school calendars, college/admissions parent newsletters (e.g. "Parent and Family Newsletter"), Google Classroom summaries, FACTS bulletins, PowerSchool broadcasts, and weekly school newsletters -> "04_Family_Health", sub-label "Family/School-Student", category: "Updates", action: "archive". Ingest and log them to notes, but keep them archived out of the Inbox (Updates tab).\n' + + '• Direct 1:1 personal emails from an individual teacher, principal, or guidance counselor addressed personally to the parent regarding an individual student\'s urgent academic/disciplinary matter or conference request -> category: "Primary", action: "keep".\n\n' + + 'NON-PROFIT & CHARITY BROADCASTS VS DIRECT CORRESPONDENCE:\n' + + '• Routine mass newsletters, monthly impact reports, event notices, general fundraising appeals, and volunteer list blasts from non-profits or charities (e.g. Community Food Bank, The Lovelady Center, Southeastern Climbers Coalition, Grace Klein Community mass mail, Restoration Academy bulletins) -> "07_Community_NonProfit", sub-label "Projects/Charity", category: "Updates", action: "archive". Ingest and log them to notes, but keep them archived out of the Inbox.\n' + + '• Direct 1:1 personal emails from non-profit leadership, board members, or staff addressed specifically to the individual (or personal volunteer shift confirmations needing attendance) -> "07_Community_NonProfit", category: "Primary", action: "keep".\n\n' + + 'TECH PLATFORM NOTIFICATIONS & SYSTEM ANNOUNCEMENTS:\n' + + '• Automated developer platform notifications, deprecation alerts, quota notices, CI/CD run failure notices (e.g. Google AI Studio, Linear, GitHub, Cloud monitoring) -> "05_Tech_Infrastructure", sub-label "Tech/Alerts", category: "Updates", action: "archive". Ingest and log them to notes, but keep them archived out of the Inbox.\n' + + '• Critical security incidents or billing payment failure notices requiring manual human intervention -> "05_Tech_Infrastructure", sub-label "Tech/Security" or "Tech/Alerts", category: "Primary", action: "keep".\n\n' + + 'PRIMARY PARTY ATTRIBUTION PRINCIPLE (CHILDREN & PRIMARY BENEFICIARY VS INCIDENTAL ADULTS):\n' + + '• When an email, legal document, school notification, or healthcare record pertains to a specific child/individual (e.g. Tide, Toby, David, or any household child), classify the email under that primary party\'s specific label (e.g. "Family/Kids/Tide", "Family/Kids/Toby", "Family/Kids/David", or "Family/School-Student").\n' + + '• Attribute the email strictly to the PRIMARY SUBJECT / TARGET BENEFICIARY of the matter, rather than incidental parties:\n' + + ' - If an email concerns Tide\'s legal name change, government IDs, FSA ID, schooling, or medical care, the primary party is Tide -> "Family/Kids/Tide" (or "02_Finance_Legal" / "Family/Legal" if a formal court filing).\n' + + ' - Route child-related legal, educational, or personal matters to the child\'s sub-label, ensuring that mentioned biological parents, guardians, or relatives do not divert the classification to "Family/Sisters".\n' + + ' - Sub-label "Family/Sisters" applies strictly to direct personal correspondence regarding the sister\'s own independent adult personal affairs.\n\n' + + '=== TIER 2: ORTHOGONAL TRIAGE MATRIX (LIFECYCLE STATE) ===\n' + + 'Determine category and action based on the lifecycle state of the email:\n' + + '1. Action_Required (Manual bills due without auto-pay, direct 1:1 personal/teacher/counselor correspondence needing individual human reply, audit/response deadlines, suspicious login alerts, ready prescriptions):\n' + + " -> category: 'Primary', action: 'keep'\n" + + '2. Informational_Feed (Routine school bulletins/newsletters/ParentSquare updates, non-profit newsletters/appeals, automated search/monitoring alerts, active orders in transit, routine tax forms/receipts, volunteer reminders, upcoming travel itineraries):\n' + + " -> category: 'Updates', action: 'archive'\n" + + '3. Completed_Transaction (Confirmed scheduled auto-payments, delivered packages, successful SSO/sign-ins, routine lunch menu digests, bank transfers, automated machine/sensor telemetry [BroodMinder, apiary events, HoneyBeeham], device/server pings):\n' + + " -> category: 'Updates', action: 'archive'\n" + + '4. Broadcast_Marketing (Commercial promos, retail discounts, vendor newsletters, terms of service and privacy policy updates):\n' + + " -> category: 'Promotions', action: 'archive'\n" + + '5. Spam_Solicitation (Phishing, scam attempts, cold wholesaler pitches):\n' + + " -> category: 'Promotions', action: 'trash'\n\n" + + 'CATEGORY-DRIVEN INBOX RETENTION INVARIANT & PRIMARY INBOX PURITY RULE (ACTION-FIRST INBOX):\n' + + '• Primary Inbox is an Action Queue: Only emails categorized as "Primary" belong in the Primary Inbox (action: "keep").\n' + + '• Emails that do not require prompt, personalized human action (such as automated newsletters, order receipts, shipping updates, travel confirmations, account alerts, credit scores, smart reorders, school bulletins, marketing promos, and social pings) MUST NOT stay in Primary (they must be categorized as "Updates", "Promotions", or "Social" with action: "archive"). Reserve category: "Primary" and action: "keep" ONLY for authentic human-to-human correspondence, critical urgent action items, or manual bills needing immediate human review.\n' + + '• All non-Primary streams ("Updates", "Promotions", "Social", "Forums") are Informational Feeds that must be archived out of the Inbox (action: "archive") into their respective category tab and domain sub-label.\n\n' + + '=== CONSTRAINTS ===\n' + + '• Single Sub-Label Invariant: Return AT MOST ONE sub-label string (e.g. "Finance/Banking" or "Family/School-Student"). Do not stack or combine multiple sub-labels.\n\n' + + if (config && config.customPromptRules) { + prompt += + '=== TIER 3: USER ENTITY KNOWLEDGE GRAPH & CUSTOM RULES ===\n' + + config.customPromptRules + + '\n\n' + } + + prompt += + 'Sender: ' + + sender + + '\n' + + 'Subject: ' + + subject + + '\n' + + 'Body Snippet: ' + + snippet + + '\n\n' + + 'Return JSON ONLY: {"canonicalDomain": "01_Household", "subLabel": "Household/Property", "category": "Updates", "action": "archive", "confidence": 0.98, "title": "Short Title", "summary": "2 sentence executive summary"}\n' + + "Valid categories: 'Primary', 'Updates', 'Promotions', 'Social', 'Forums'.\n" + + "Valid actions: 'keep', 'archive', 'trash', 'mark_read'." + + return prompt +} + /** * Calls the Gemini REST API and returns a validated classification object, or null. * @@ -146,9 +330,444 @@ function classifyEmailWithGemini( return null } } +} + +function computeStringHash(str, services) { + if (!str) return '' + try { + var utils = + (services && services.Utilities) || + (typeof Utilities !== 'undefined' ? Utilities : null) + if (utils && typeof utils.computeDigest === 'function') { + var algo = (utils.DigestAlgorithm && utils.DigestAlgorithm.MD5) || 'MD5' + var digest = utils.computeDigest(algo, str) + return digest + .map(function (b) { + return ('0' + (b & 0xff).toString(16)).slice(-2) + }) + .join('') + } + } catch { + // Non-blocking fallback + } + var hash = 0 + for (var i = 0; i < str.length; i++) { + hash = Math.trunc((hash << 5) - hash + str.charCodeAt(i)) + } + return 'h_' + Math.abs(hash).toString(16) +} + +function classifyWithGemini(sender, subject, snippet, config, services) { + var urlFetch = + (services && services.UrlFetchApp) || + (typeof UrlFetchApp !== 'undefined' ? UrlFetchApp : null) + var sleepFn = + (services && services.sleepFn) || + (services && services.Utilities && services.Utilities.sleep) || + (typeof Utilities !== 'undefined' && Utilities && Utilities.sleep + ? Utilities.sleep + : function () {}) + var cache = + (services && services.CacheService) || + (typeof CacheService !== 'undefined' && + CacheService && + CacheService.getScriptCache + ? CacheService.getScriptCache() + : null) + var props = + (services && + services.PropertiesService && + typeof services.PropertiesService.getScriptProperties === 'function' + ? services.PropertiesService.getScriptProperties() + : null) || + (services && services.properties) || + (typeof PropertiesService !== 'undefined' && + typeof PropertiesService.getScriptProperties === 'function' + ? PropertiesService.getScriptProperties() + : null) + + var canonicalDomains = config && config.canonicalDomains + + // 1. Check classification cache + var cacheKey = null + if (cache) { + var rawKey = (sender || '') + '|' + (subject || '') + '|' + (snippet || '') + var hashed = computeStringHash(rawKey, services) + if (hashed) { + cacheKey = 'cls_' + hashed + try { + var cachedJson = cache.get(cacheKey) + if (cachedJson) { + var cachedResult = JSON.parse(cachedJson) + if ( + validateOntologicalClassification(cachedResult, canonicalDomains) + ) { + console.log('[classifyWithGemini] Cache hit for: ' + subject) + return cachedResult + } + } + } catch { + // Cache read is non-blocking + } + } + } + + // 2. Check circuit breaker before hitting remote endpoints + if (isCircuitBreakerOpen(props)) { + console.warn( + '[classifyWithGemini] Circuit breaker is open. Skipping remote API call.' + ) + return null + } + + var prompt = buildOntologicalPrompt(config, sender, subject, snippet) + + var payload = { + contents: [ + { + parts: [{ text: prompt }], + }, + ], + generationConfig: { response_mime_type: 'application/json' }, + } + + // Cascading Gemini Model Matrix (Production GA models preferred) + var endpoints = [] + if (config && config.modelEndpoint) { + endpoints.push(config.modelEndpoint) + } + var fallbackModels = [ + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent', + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent', + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent', + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent', + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent', + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-latest:generateContent', + ] + for (var f = 0; f < fallbackModels.length; f++) { + if (endpoints.indexOf(fallbackModels[f]) === -1) { + endpoints.push(fallbackModels[f]) + } + } + + if (!urlFetch) { + console.error('[classifyWithGemini] UrlFetchApp service is not available.') + return null + } + + var maxRetryDelayMs = null + + for (var e = 0; e < endpoints.length; e++) { + var url = endpoints[e] + var options = { + method: 'post', + contentType: 'application/json', + headers: { + 'x-goog-api-key': (config && config.geminiApiKey) || '', + }, + payload: JSON.stringify(payload), + muteHttpExceptions: true, + } + + try { + var response = urlFetch.fetch(url, options) + var statusCode = response.getResponseCode() + var jsonText = response.getContentText() + + if (statusCode === 200) { + var resData = JSON.parse(jsonText) + if ( + resData.candidates && + resData.candidates[0] && + resData.candidates[0].content && + resData.candidates[0].content.parts && + resData.candidates[0].content.parts[0] + ) { + var textOutput = resData.candidates[0].content.parts[0].text + var parsedObj = extractJsonSubstring(textOutput) + if ( + parsedObj && + validateOntologicalClassification(parsedObj, canonicalDomains) + ) { + console.log( + '[classifyWithGemini] Success using endpoint: ' + endpoints[e] + ) + if (props) { + recordCircuitBreakerSuccess(props) + } + if (cache && cacheKey) { + try { + cache.put(cacheKey, JSON.stringify(parsedObj), 21600) + } catch { + // Cache write is non-blocking + } + } + return parsedObj + } + console.warn( + '[classifyWithGemini] Invalid ontological response schema from endpoint: ' + + endpoints[e] + ) + } + } else if (statusCode === 429) { + var delayMs = parseRetryDelayMs(response) + if (maxRetryDelayMs === null || delayMs > maxRetryDelayMs) { + maxRetryDelayMs = delayMs + } + console.warn( + '[classifyWithGemini] Endpoint ' + + endpoints[e] + + ' HTTP 429 Rate Limit. Honoring Retry-After / retryDelay: sleeping ' + + delayMs / 1000 + + 's before fallback...' + ) + sleepFn(delayMs) + } else { + console.warn( + '[classifyWithGemini] Endpoint ' + + endpoints[e] + + ' HTTP ' + + statusCode + + ': ' + + jsonText + ) + } + } catch (err) { + console.warn( + '[classifyWithGemini] Exception on endpoint ' + + endpoints[e] + + ': ' + + err.message + ) + if ( + err.message && + err.message.indexOf('Service invoked too many times') !== -1 + ) { + console.error( + '[classifyWithGemini] UrlFetchApp daily quota exhausted. Halting remote API calls.' + ) + break + } + } + } + + console.error( + '[classifyWithGemini] All model endpoints failed or rate-limited.' + ) + if (props) { + recordCircuitBreakerFailure(props, maxRetryDelayMs) + } + return null +} + +function extractJsonSubstring(text) { + if (!text) return null + text = text + .replace(/```json/gi, '') + .replace(/```/gi, '') + .trim() + + var start = text.indexOf('{') + var end = text.lastIndexOf('}') + if (start !== -1 && end !== -1 && end > start) { + var rawJson = text.substring(start, end + 1) + try { + return JSON.parse(rawJson) + } catch { + try { + var cleaned = rawJson.replace(/,\s*([\]}])/g, '$1') + return JSON.parse(cleaned) + } catch (e2) { + console.warn( + '[extractJsonSubstring] JSON parse error on raw substring:', + e2.message + ) + } + } + } return null } +function parseRetryDelayMs(response) { + try { + var headers = + (response && typeof response.getHeaders === 'function' + ? response.getHeaders() + : null) || {} + var retryHeader = headers['Retry-After'] || headers['retry-after'] + if (retryHeader) { + var seconds = Number.parseInt(retryHeader, 10) + if (!Number.isNaN(seconds) && seconds > 0) { + return Math.min(seconds * 1000, 30000) + } + } + + var jsonText = + (response && typeof response.getContentText === 'function' + ? response.getContentText() + : null) || '' + if (jsonText) { + var resData = JSON.parse(jsonText) + if (resData.error && resData.error.details) { + for (var i = 0; i < resData.error.details.length; i++) { + var detail = resData.error.details[i] + if (detail.retryDelay) { + var secStr = detail.retryDelay.replace('s', '') + var sec = Number.parseFloat(secStr) + if (!Number.isNaN(sec) && sec > 0) { + return Math.min(Math.ceil(sec * 1000), 30000) + } + } + } + } + } + } catch (e) { + console.warn('[parseRetryDelayMs] Failed to parse retry delay:', e.message) + } + return 5000 +} + +/** + * Cross-execution circuit breaker state check. + * Checks whether remote AI calls should be skipped due to active backoff. + */ +function isCircuitBreakerOpen(props, nowMs) { + if (!props || typeof props.getProperty !== 'function') return false + var untilStr = props.getProperty('GEMINI_BACKOFF_UNTIL') + if (!untilStr) return false + var until = Number.parseInt(untilStr, 10) + if (Number.isNaN(until)) return false + var now = typeof nowMs === 'number' ? nowMs : Date.now() + return now < until +} + +/** + * Records a remote API failure in PropertiesService, applying exponential backoff. + */ +function recordCircuitBreakerFailure(props, retryDelayMs, nowMs) { + if (!props || typeof props.setProperty !== 'function') return 0 + var now = typeof nowMs === 'number' ? nowMs : Date.now() + var currentFailures = 0 + if (typeof props.getProperty === 'function') { + var fStr = props.getProperty('GEMINI_BACKOFF_FAILURES') + currentFailures = Number.parseInt(fStr || '0', 10) + if (Number.isNaN(currentFailures)) currentFailures = 0 + } + var failures = currentFailures + 1 + props.setProperty('GEMINI_BACKOFF_FAILURES', String(failures)) + + var backoffMs = 60000 * Math.pow(2, failures - 1) + if (typeof retryDelayMs === 'number' && retryDelayMs > backoffMs) { + backoffMs = retryDelayMs + } + if (backoffMs > 3600000) { + backoffMs = 3600000 + } + var backoffUntil = now + backoffMs + props.setProperty('GEMINI_BACKOFF_UNTIL', String(backoffUntil)) + return backoffMs +} + +/** + * Resets the cross-execution circuit breaker upon a successful remote API call. + */ +function recordCircuitBreakerSuccess(props) { + if (!props) return + if (typeof props.deleteProperty === 'function') { + props.deleteProperty('GEMINI_BACKOFF_UNTIL') + props.deleteProperty('GEMINI_BACKOFF_FAILURES') + } else if (typeof props.setProperty === 'function') { + props.setProperty('GEMINI_BACKOFF_UNTIL', '') + props.setProperty('GEMINI_BACKOFF_FAILURES', '0') + } +} + +/** + * Retrieves the retry failure count for a thread from cache. + */ +function getThreadRetryCount_(thread, cache) { + if (!thread || !cache || typeof cache.get !== 'function') return 0 + var threadId = typeof thread.getId === 'function' ? thread.getId() : '' + if (!threadId) return 0 + var val = cache.get('retry_count_' + threadId) + var count = Number.parseInt(val || '0', 10) + return Number.isNaN(count) ? 0 : count +} + +/** + * Increments the retry failure count for a thread in cache (6h TTL). + */ +function incrementThreadRetryCount_(thread, cache) { + if (!thread || !cache || typeof cache.put !== 'function') return 1 + var threadId = typeof thread.getId === 'function' ? thread.getId() : '' + if (!threadId) return 1 + var count = getThreadRetryCount_(thread, cache) + 1 + try { + cache.put('retry_count_' + threadId, String(count), 21600) + } catch { + // Cache write is non-blocking + } + return count +} + +/** + * Clears the retry failure count for a thread from cache. + */ +function clearThreadRetryCount_(thread, cache) { + if (!thread || !cache || typeof cache.remove !== 'function') return + var threadId = typeof thread.getId === 'function' ? thread.getId() : '' + if (!threadId) return + try { + cache.remove('retry_count_' + threadId) + } catch { + // Non-blocking + } +} + +function listAvailableGeminiModels(config, services) { + var urlFetch = + (services && services.UrlFetchApp) || + (typeof UrlFetchApp !== 'undefined' ? UrlFetchApp : null) + if (!urlFetch) return [] + var cfg = + config || + (typeof getAiClassifierConfig === 'function' ? getAiClassifierConfig() : {}) + if (!cfg || !cfg.geminiApiKey) return [] + var url = 'https://generativelanguage.googleapis.com/v1beta/models' + try { + var response = urlFetch.fetch(url, { + headers: { + 'x-goog-api-key': cfg.geminiApiKey, + }, + muteHttpExceptions: true, + }) + if (response.getResponseCode() === 200) { + var data = JSON.parse(response.getContentText()) + var modelNames = (data.models || []).map(function (m) { + return m.name + }) + console.log( + '[listAvailableGeminiModels] Available models for key:', + JSON.stringify(modelNames) + ) + return modelNames + } else { + console.warn( + '[listAvailableGeminiModels] HTTP ' + + response.getResponseCode() + + ': ' + + response.getContentText() + ) + } + } catch (e) { + console.warn( + '[listAvailableGeminiModels] Error querying models:', + e.message + ) + } + return [] +} + /** * Ensures a Gmail user label exists, creating it if necessary. * @@ -156,12 +775,73 @@ function classifyEmailWithGemini( * @param {Object} gmailApp - GAS GmailApp service (injected for testability) * @returns {Object|null} GmailLabel or null on failure */ -function ensureGmailLabel(labelName, gmailApp) { +function ensureGmailLabel(labelName, gmailApp, gmailAdvanced) { + if ( + !gmailApp || + !labelName || + typeof gmailApp.getUserLabelByName !== 'function' + ) { + return null + } const existing = gmailApp.getUserLabelByName(labelName) if (existing) return existing + + if (typeof gmailApp.getUserLabels === 'function') { + const allLabels = gmailApp.getUserLabels() || [] + for (let i = 0; i < allLabels.length; i++) { + if ( + allLabels[i] && + typeof allLabels[i].getName === 'function' && + allLabels[i].getName().toLowerCase() === labelName.toLowerCase() + ) { + return allLabels[i] + } + } + } + + if (labelName.indexOf('/') !== -1) { + const parts = labelName.split('/') + let currentPath = '' + for (let p = 0; p < parts.length - 1; p++) { + currentPath = currentPath ? currentPath + '/' + parts[p] : parts[p] + ensureGmailLabel(currentPath, gmailApp, gmailAdvanced) + } + } + try { + if (gmailAdvanced?.Users?.Labels?.create) { + try { + gmailAdvanced.Users.Labels.create( + { + name: labelName, + labelListVisibility: 'labelShow', + messageListVisibility: 'hide', + }, + 'me' + ) + const created = gmailApp.getUserLabelByName(labelName) + if (created) return created + } catch { + // Fall back to standard createLabel + } + } return gmailApp.createLabel(labelName) } catch (e) { + try { + if (typeof gmailApp.getUserLabels === 'function') { + const existingLabels = gmailApp.getUserLabels() || [] + for (let j = 0; j < existingLabels.length; j++) { + if ( + existingLabels[j] && + typeof existingLabels[j].getName === 'function' && + existingLabels[j].getName().toLowerCase() === + labelName.toLowerCase() + ) { + return existingLabels[j] + } + } + } + } catch {} console.error( '[ensureGmailLabel] Error creating label:', labelName, @@ -189,6 +869,8 @@ function createPermanentGmailFilter( gmailService, gmailApp ) { + if (!senderEmail || typeof senderEmail !== 'string') return false + if (!labelName || typeof labelName !== 'string') return false const ltIdx = senderEmail.indexOf('<') const gtIdx = senderEmail.indexOf('>', ltIdx + 1) const cleanSender = @@ -248,108 +930,5129 @@ function createPermanentGmailFilter( } /** - * Processes a batch of Gmail threads: classify, label, and optionally create filters. + * Sets the Gmail system category tab (Primary, Updates, Promotions, Social, Forums) + * via the Advanced Gmail API. * - * @param {Object[]} threads - Array of GmailThread objects - * @param {Object} config - Classifier configuration - * @param {Object} services - Injected GAS services: { GmailApp, UrlFetchApp, Gmail } - * @returns {Object[]} Array of result objects describing outcome per thread + * @param {Object} thread - GmailThread object + * @param {string} targetCategory - One of 'Primary', 'Updates', 'Promotions', 'Social', 'Forums' + * @param {Object} [gmailService] - Optional injected Advanced Gmail service (defaults to global Gmail) */ -function processThreadBatch(threads, config, services) { - const processedLabel = ensureGmailLabel( - config.processedLabel, - services.GmailApp - ) - if (!processedLabel) { - console.error( - '[processThreadBatch] Processed label unavailable; aborting batch to avoid reprocessing.' - ) - return [] +function setGmailCategoryTab(thread, targetCategory, gmailService) { + if (!thread || typeof thread.getId !== 'function') { + return + } + var gmail = gmailService || (typeof Gmail !== 'undefined' ? Gmail : null) + if ( + !gmail || + !gmail.Users || + !gmail.Users.Threads || + !gmail.Users.Threads.modify + ) { + return } - const results = [] - threads.forEach(function (thread) { - const messages = thread.getMessages() - if (!messages || messages.length === 0) { - results.push({ threadId: thread.getId(), status: 'empty' }) - return - } + var catMap = { + Primary: 'CATEGORY_PERSONAL', + Updates: 'CATEGORY_UPDATES', + Promotions: 'CATEGORY_PROMOTIONS', + Social: 'CATEGORY_SOCIAL', + Forums: 'CATEGORY_FORUMS', + } - const latestMsg = messages[0] - const sender = latestMsg.getFrom() - const subject = latestMsg.getSubject() - const bodySnippet = latestMsg.getPlainBody() - ? latestMsg.getPlainBody().substring(0, 1200) - : '' + var targetId = catMap[targetCategory] + if (!targetId) return - const classification = classifyEmailWithGemini( - config, - sender, - subject, - bodySnippet, - services.UrlFetchApp - ) - if (!classification) { - console.log( - '[processThreadBatch] Could not classify thread:', - thread.getId() - ) - results.push({ threadId: thread.getId(), status: 'unclassified' }) - return - } + var allCategoryIds = [ + 'CATEGORY_PERSONAL', + 'CATEGORY_UPDATES', + 'CATEGORY_PROMOTIONS', + 'CATEGORY_SOCIAL', + 'CATEGORY_FORUMS', + ] + var removeIds = allCategoryIds.filter(function (id) { + return id !== targetId + }) + + try { + gmail.Users.Threads.modify( + { + addLabelIds: [targetId], + removeLabelIds: removeIds, + }, + 'me', + thread.getId() + ) + var threadSubject = + typeof thread.getFirstMessageSubject === 'function' + ? thread.getFirstMessageSubject() + : thread.getId() console.log( - '[processThreadBatch] Classified as:', - classification.canonical_label, - '(Confidence:', - classification.confidence, - ')' + '[setGmailCategoryTab] Assigned category ' + + targetId + + ' to thread: ' + + threadSubject ) - - const categoryLabel = ensureGmailLabel( - classification.canonical_label, - services.GmailApp + } catch (e) { + console.warn( + '[setGmailCategoryTab] Could not modify thread category: ' + e.message ) - if (!categoryLabel) { - console.error( - '[processThreadBatch] Failed to create category label:', - classification.canonical_label - ) - results.push({ - threadId: thread.getId(), - status: 'label_creation_failed', - }) - return - } - thread.addLabel(categoryLabel) - thread.addLabel(processedLabel) + } +} - let filterCreated = false - if (classification.confidence >= config.autoFilterConfidenceThreshold) { - filterCreated = createPermanentGmailFilter( - sender, - classification.canonical_label, - services.Gmail, - services.GmailApp - ) +/** + * Removes any pre-existing conflicting Core Domain labels, Sub-Labels, legacy flat labels, + * and redundant 'Retention/Permanent' tags to enforce a strict, clean label budget. + */ +function cleanConflictingLabels(thread, targetDomain, targetSubLabel, config) { + if (!thread || typeof thread.getLabels !== 'function') return + try { + var existingLabels = thread.getLabels() || [] + var canonicalDomains = (config && config.canonicalDomains) || [] + + var legacyFlatLabels = [ + 'Finance', + 'Household', + 'Family', + 'Projects', + 'Work', + 'Community', + 'Tech', + 'Purchases', + 'Banking', + 'Bills', + 'eBills', + 'Insurance', + 'Sent', + 'Archives', + ] + + var removeLabelSafe = function (lObj, msgPrefix) { + try { + thread.removeLabel(lObj) + console.log('[cleanConflictingLabels] ' + msgPrefix) + } catch (err) { + console.warn( + '[cleanConflictingLabels] Failed ' + msgPrefix + ': ' + err.message + ) + } } - results.push({ - threadId: thread.getId(), + for (var j = 0; j < existingLabels.length; j++) { + var lObj = existingLabels[j] + var lName = + typeof lObj.getName === 'function' ? lObj.getName() : String(lObj) + + // 1. Strip redundant Retention/Permanent (Inverted Model: blank = permanent) + if (lName === 'Retention/Permanent') { + removeLabelSafe(lObj, 'Stripped Retention/Permanent: ' + lName) + continue + } + + // 2. Protect valid short-lived retention expiration tags + if (lName.indexOf('Retention/') === 0) { + continue + } + + // 3. Protect global status and system labels + if (lName === ((config && config.processedLabel) || 'Processed')) { + continue + } + + // 4. Clean legacy flat labels + var isLegacy = false + for (var f = 0; f < legacyFlatLabels.length; f++) { + if (lName.toLowerCase() === legacyFlatLabels[f].toLowerCase()) { + removeLabelSafe(lObj, 'Stripped legacy flat label: ' + lName) + isLegacy = true + break + } + } + if (isLegacy) continue + + // 5. Clean ALL Core Domain labels (threads only carry Sub-Labels, never raw domain codes) + var isDomainCode = + isRootDomainLabel(lName) || canonicalDomains.indexOf(lName) !== -1 + if (isDomainCode) { + removeLabelSafe(lObj, 'Removed domain code label: ' + lName) + continue + } + + // 6. Clean conflicting sub-labels if they don't match targetSubLabel + if ( + lName.indexOf('/') !== -1 && + lName !== targetSubLabel && + lName.indexOf('Archives') === -1 && + lName.indexOf('Retention/') === -1 + ) { + removeLabelSafe(lObj, 'Removed conflicting sub-label: ' + lName) + } + } + } catch (e) { + console.warn('[cleanConflictingLabels] Error cleaning labels: ' + e.message) + } +} + +/** + * Extracts the normalized email address from a From header string. + * + * @param {string} sender - Raw sender header + * @returns {string} Lowercase email address or empty string + */ +function extractSenderAddress_(sender) { + const raw = String(sender || '') + .trim() + .toLowerCase() + const angleMatch = /<([^<>@\s]+@[^<>@\s]+)>/.exec(raw) + if (angleMatch) return angleMatch[1] + const plainMatch = /([^\s<>,"']+@[a-z0-9.-]+\.[a-z]{2,})/i.exec(raw) + return plainMatch ? plainMatch[1].toLowerCase() : '' +} + +/** + * Extracts the hostname portion of the sender's email address. + * + * @param {string} sender - Raw sender header + * @returns {string} Lowercase domain host or empty string + */ +function extractSenderHost_(sender) { + const addr = extractSenderAddress_(sender) + if (addr) { + const atIdx = addr.lastIndexOf('@') + return atIdx === -1 ? '' : addr.slice(atIdx + 1) + } + const raw = String(sender || '') + .trim() + .toLowerCase() + return /^[a-z0-9.-]+\.[a-z]{2,}$/.test(raw) ? raw : '' +} + +function toTermList_(items) { + if (!items) return [] + return Array.isArray(items) ? items : String(items).split('|') +} + +/** + * Checks whether the sender's hostname exactly matches or is a subdomain of + * any domain in the provided list. Prevents substring false positives (e.g. + * 'x.com' matching 'netflix.com' or 'fedex.com'). + * + * @param {string} sender - Raw sender header + * @param {string[]|string} domains - Candidate root/sub domains (array or pipe-delimited) + * @returns {boolean} True if sender host matches any domain + */ +function matchesSenderDomain_(sender, domains) { + const host = extractSenderHost_(sender) + const list = toTermList_(domains) + if (!host || list.length === 0) return false + for (let i = 0; i < list.length; i++) { + const target = String(list[i] || '') + .trim() + .toLowerCase() + if (target && (host === target || host.endsWith('.' + target))) { + return true + } + } + return false +} + +/** + * Checks whether the sender's email address exactly matches any address in the list. + * + * @param {string} sender - Raw sender header + * @param {string[]|string} addresses - Candidate email addresses (array or pipe-delimited) + * @returns {boolean} True if sender address matches + */ +function matchesSenderAddress_(sender, addresses) { + const addr = extractSenderAddress_(sender) + const list = toTermList_(addresses) + if (!addr || list.length === 0) return false + for (let i = 0; i < list.length; i++) { + const target = String(list[i] || '') + .trim() + .toLowerCase() + if (target && addr === target) return true + } + return false +} + +/** + * Checks whether the lowercased text contains any of the given substrings. + * + * @param {string} textLower - Lowercased hay-stack string + * @param {string[]|string} terms - Lowercase needle terms (array or pipe-delimited) + * @returns {boolean} True if any term is found + */ +function containsAny_(textLower, terms) { + const list = toTermList_(terms) + if (!textLower || list.length === 0) return false + for (let i = 0; i < list.length; i++) { + if (list[i] && textLower.indexOf(list[i]) !== -1) return true + } + return false +} + +/** + * Builds a normalized deterministic classification result object. + * + * @param {string|null} canonicalDomain - Canonical domain or null + * @param {string|null} subLabel - Sub-label or null + * @param {string} category - Inbox category ('Primary', 'Updates', 'Promotions', 'Social') + * @param {string} action - Action ('keep', 'archive', 'trash') + * @param {number} confidence - Confidence score (0.0 to 1.0) + * @param {string} subject - Email subject + * @param {string} summary - Classification summary + * @param {string} reasoning - Rule reasoning identifier + * @returns {Object} Classification result + */ +function makeDeterministicResult_( + canonicalDomain, + subLabel, + category, + action, + confidence, + subject, + summary, + reasoning +) { + return { + canonicalDomain: canonicalDomain, + canonical_label: canonicalDomain, + subLabel: subLabel, + category: category, + action: action, + confidence: confidence, + title: subject, + summary: summary, + reasoning: reasoning, + } +} + +const SCHOOL_DOMAINS_ = + 'bcsk12.org|bcs-lions.org|briarwoodchristianschool.org|mcaabhm.org|jeffersonstate.edu' + +const SCHOOL_BROADCAST_TERMS_ = + 'weekly|newsletter|bulletin|school update|campus update|district update|bcs update|lions update|digest|progress is ready|lunch|menu|portraits|news|noteworthy|announcement|athletics|e learning|e-learning|report card update|trunk or treat|spirit week|pto|pta|open house|picture day|fall festival|spring festival|school announcement|tryouts|tryout|try-outs|try-out|auditions|audition|team tryouts|absent|attendance|tardy|tuition|billing statement|assignment posted|new assignment|grade updated|score published|family portal|parent portal' + +const CAREER_SUBJECT_TERMS_ = + 'employment agreement|employment contract|offer letter|job offer|employment offer|terms of employment|independent contractor agreement|restrictive covenant|confidentiality agreement|interview confirmation|interview invitation|invitation to interview|interview with |schedule an interview|recruiter screen|phone screen' + +const CAREER_SNIPPET_TERMS_ = + 'employment agreement|offer of employment|terms of employment|pleased to offer you the position|pleased to offer you|pleased to confirm our offer|congratulations on your offer|employment document|restrictive covenants agreement' + +const CUSTOM_RULE_MATCHERS_ = { + nonprofit: function (ctx) { + return ( + matchesSenderDomain_( + ctx.sender, + 'ignite-hope.org|helpingoneguy.com|charitywater.org|compassion.com|worldvision.org' + ) || + containsAny_(ctx.subLower, 'ironmen|iron men') || + (containsAny_(ctx.sLower, 'newsletter|update|impact') && + containsAny_( + ctx.subLower, + 'impact update|ministry update|community update' + )) + ) + }, + saasPlan: function (ctx) { + return ( + containsAny_( + ctx.subLower, + 'trial is ending|trial ending|trial will expire|trial expired|trial expiration|free trial is ending|free trial ends' + ) || + (ctx.subLower.indexOf('aws') !== -1 && + containsAny_( + ctx.subLower, + 'upgraded to a paid plan|free plan is ready' + )) || + ((matchesSenderDomain_( + ctx.sender, + 'aws.com|amazonaws.com|digitalocean.com|cloudflare.com|openai.com|anthropic.com|supabase.com|neon.tech' + ) || + ctx.sLower.indexOf('azure') !== -1) && + containsAny_( + ctx.subLower, + 'upgraded to a paid plan|free plan is ready|free tier|startup credits|account plan' + )) + ) + }, + schoolBroadcast: function (ctx) { + const isParentSquare = matchesSenderDomain_(ctx.sender, 'parentsquare.com') + return ( + ctx.isBrightArrow || + ((ctx.isSchoolDomain || + isParentSquare || + matchesSenderAddress_(ctx.sender, 'admissions@ua.edu')) && + (ctx.isBroadcastSubject || isParentSquare)) + ) + }, + schoolTravel: function (ctx) { + return ( + matchesSenderDomain_( + ctx.sender, + 'ef.com|eftours.com|worldstrides.com|explorica.com|acis.com' + ) || + (containsAny_(ctx.subLower, 'trip to |school travel|educational tour') && + containsAny_( + ctx.subLower, + 'interest meeting|informational meeting|rsvp|enrollment discount' + )) + ) + }, + bankRate: function (ctx) { + return ( + containsAny_( + ctx.subLower, + 'higher apy|apy has increased|apy increased|earns a higher apy|rate increase|rate decrease|rate update|rates are changing|yield update|interest rate increase|savings rate increase|annual percentage yield' + ) || + (ctx.subLower.indexOf('rate change') !== -1 && + containsAny_(ctx.subLower, 'apy|cash|savings|interest')) + ) + }, +} + +/** + * Declarative deterministic rule specifications. + * Format: mode::domains::addresses::senderTerms::subjectTerms::excludeSubject::domain::subLabel::category::action::confidence::reasoning::summary + */ +const DETERMINISTIC_RULE_SPECS_ = [ + 'any::::::::your day ahead::::01_Household::Household/Property::Updates::archive::0.99::Daily Digest::Automated daily agenda digest routed to Updates.', + 'sender+sub::amazon.com|hp.com|brother.com|canon.com|epson.com::auto-reorder@amazon.com|dash@amazon.com::instantink::time to order the|time to reorder|smart reorder|auto-reorder|replenishment|low ink|ink alert|toner low|toner alert|toner for your|replacement filter|reorder your::::01_Household::Household/Supplies::Updates::archive::0.99::Smart Reorder::Automated smart reorder or consumable replenishment alert routed to Updates.', + 'any::broodminder.com|mybroodminder.com::::broodminder|mybroodminder::hive alert|events in your apiaries|events in your apiary|event in your apiaries|event in your apiary|apiary alert|apiary event::::07_Community_NonProfit::Projects/Telemetry::Updates::archive::0.99::BroodMinder Telemetry::Automated hive and apiary telemetry alert routed to Projects/Telemetry.', + 'any::qnap.com::::qts notification|notificationcenter::[qnap|qts notification|qvr pro|qulog::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::QNAP Telemetry::QNAP NAS system health, RAID, backup, or QVR surveillance telemetry alert routed to Tech/Alerts.', + 'any::ui.com|ubnt.com::::::unifi::verification code|mfa|one-time|security code::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::UniFi Telemetry::UniFi Dream Machine or network infrastructure alert routed to Tech/Alerts.', + 'any::nabucasa.com|home-assistant.io::::::home assistant::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::Home Assistant Telemetry::Home Assistant or Nabu Casa cloud telemetry/subscription alert routed to Tech/Alerts.', + 'custom:nonprofit::::::::::::07_Community_NonProfit::NonProfit/General::Updates::archive::0.98::NonProfit Impact Newsletter::Community non-profit organization newsletter, Bible study digest, or project impact update routed to Updates.', + 'any::linear.app::::::::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::Linear Alert::Linear task and project notification digest routed to Tech/Alerts.', + 'any::notifications.cubic.dev|cubic.dev::::cubic::::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::Cubic Code Review::Cubic AI automated code review notice routed to Tech/Alerts.', + 'any::::notifications@github.com::::::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::GitHub Alert::GitHub CI/CD workflow run or notification routed to Tech/Alerts.', + 'any::sonarcloud.io|sentry.io|vercel.com::noreply-apps-scripts-notifications@google.com::::::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::Developer Monitoring Alert::CI/CD code quality, error monitoring, or deployment alert routed to Tech/Alerts.', + 'any::::googleaistudio-noreply@google.com::::::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::Google AI Studio Alert::Google AI Studio API quota, model deprecation, or project update routed to Tech/Alerts.', + 'any::updates.codeant.ai|codeant.ai::::::::::::::Promotions::archive::0.98::DevTool Promo::Developer tooling promotional or product feature announcement routed to Promotions.', + 'any::::calendar-notification@google.com::::::::01_Household::Household/Property::Updates::archive::0.99::Calendar Notification::Google Calendar event reminder notification routed to Updates.', + 'custom:saasPlan::::::::::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::SaaS / Cloud Plan Alert::Cloud provider tier status, SaaS trial expiration, or account plan notification routed to Tech/Alerts.', + 'any::pypi.org|npmjs.com|docker.com|crates.io::::::[pypi]|trusted publisher::::05_Tech_Infrastructure::Tech/Alerts::Updates::archive::0.99::Package Registry Alert::Package repository publishing, release, or trusted publisher notification routed to Tech/Alerts.', + 'any::::::::you shared some google account data with|google account data with|granted access to your google account|third-party access|app access granted::::05_Tech_Infrastructure::Tech/Security::Primary::keep::1::Third-Party Authorization Shield::Third-party application account data sharing or authorization notice preserved in Primary Inbox as security warning.', + 'any::::::::verification code|security code|one-time passcode|one-time password|your login code|new sign-in|signed in from a new|new device signed in|sign-in from new device|security alert for your|password changed|password reset confirmation::::05_Tech_Infrastructure::Tech/Security::Updates::archive::0.99::Authentication/Sign-in Ping::Automated authentication verification code or device sign-in alert routed to Tech/Security.', + 'sender+sub::cvs.com|walgreens.com|questdiagnostics.com|labcorp.com|zocdoc.com|athenahealth.com::::mychart|cvshealth::appointment reminder|upcoming appointment|appointment confirmation|appointment is confirmed|prescription is ready|prescription ready|refill reminder|refill confirmation::action required|abnormal|urgent|past due|overdue::04_Family_Health::Family/Health-Medical::Updates::archive::0.99::Healthcare/Pharmacy Alert::Automated patient portal appointment reminder or pharmacy prescription status notice.', + 'sender+sub::delta.com|southwest.com|aa.com|united.com|airbnb.com|vrbo.com|marriott.com|hilton.com|uber.com|lyft.com|waymo.com::::waymo::flight confirmation|flight status|boarding pass|check in for your flight|reservation confirmed|reservation confirmation|upcoming stay|trip with uber|ride with lyft|waymo ride|riding with waymo|receipt::::01_Household::Household/Travel::Updates::archive::0.99::Travel Confirmation::Travel itinerary reservation, flight check-in, or rideshare receipt.', + 'any::::::::how was your experience|how did we do|rate your recent|rate your experience|rate your stay|tell us what you think|take our survey|take our quick survey|quick survey|feedback requested|review your purchase|leave a review|your opinion matters::::::::Promotions::archive::0.99::Customer Survey::Automated post-transaction customer satisfaction feedback or survey request.', + 'any::ifttt.com|notify.mios.com::::::::::01_Household::Household/Property::Updates::archive::0.98::IFTTT / Weather Alert::Automated weather or IoT trigger notification routed to Household/Property.', + 'custom:schoolBroadcast::::::::::::04_Family_Health::Family/School-Student::Updates::archive::0.98::School Broadcast::Routine school bulletin, newsletter, BrightArrow broadcast, or ParentSquare digest routed to Updates.', + 'custom:schoolTravel::::::::::::04_Family_Health::Family/School-Student::Promotions::archive::0.99::School Travel Solicitation::Educational travel program solicitation or informational trip meeting announcement routed to Promotions.', + 'any::hello.springhealth.com|springhealth.com::::::::::04_Family_Health::Family/Health-Medical::Updates::archive::0.98::Benefits Broadcast::Routine employer healthcare benefit announcement or wellness newsletter.', + 'sender+sub::ebay.com::::::delivered|out for delivery|order confirmed|shipped::::02_Finance_Legal::Finance/Purchases::Updates::archive::0.98::Order Tracking::Online order confirmation or package delivery tracking notice.', + 'sender+sub::ups.com|fedex.com|usps.com|usps.gov::::::delivered|out for delivery|tracking|scheduled delivery|package|shipment::::02_Finance_Legal::Finance/Purchases::Updates::archive::0.98::Carrier Tracking::Carrier parcel shipment or package delivery notification.', + 'sender+sub::amazon.com::::::ordered:|shipped:|delivered:|refund issued|auto-reloaded::::02_Finance_Legal::Finance/Purchases::Updates::archive::0.98::Amazon Routine::Routine Amazon e-commerce order, shipping, or refund update.', + 'sender+sub::chase.com|bankofamerica.com|spireenergy.com|alabamapower.com|bwwb.org|message.att-mail.com|att-mail.com|citi.com|americanexpress.com|apcocu.org|rocketmortgage.com|mycirclecard.target.com::::capitalone|spectrum|citicards::statement|autopay|payment processed|payment confirmation|payment scheduled|payment received|scheduled payment|bill is ready|bill is available|e-statement|estatement::failed|failure|declined|past due|overdue|action required|unpaid|late fee|returned payment|insufficient funds::02_Finance_Legal::Finance/Bills::Updates::archive::0.98::Routine Billing::Routine billing statement, receipt, or AutoPay confirmation.', + 'sender+sub::bcbsal.org|bcbs.com|uhc.com|aetna.com|cigna.com|humana.com::::bluecrosspayments|unitedhealthcare::explanation of benefits|claim|claims|eob::::02_Finance_Legal::Finance/Insurance::Updates::archive::0.99::Insurance Claim / EOB Notice::Health insurance claim processing notice or explanation of benefits (EOB) notification routed to Finance/Insurance.', + 'sender+sub::bcbsal.org|bcbs.com|uhc.com|aetna.com|cigna.com|humana.com::::bluecrosspayments|unitedhealthcare::payment|payments|premium|statement|bill|processed|receipt::failed|failure|declined|past due|overdue|action required|unpaid|late fee|returned payment|insufficient funds::02_Finance_Legal::Finance/Bills::Updates::archive::0.99::Insurance Payment Receipt::Health insurance premium payment or processing notification routed to Finance/Bills.', + 'sender+sub::geico.com|progressive.com|statefarm.com::::::auto pay|autopay|payment|bill|statement|policy document|id card::claim|adjuster|settlement|complaint|failed|failure|declined|past due|overdue|action required|unpaid|late fee|returned payment|insufficient funds::02_Finance_Legal::Finance/Bills::Updates::archive::0.99::Auto Insurance Routine Billing::Routine auto insurance billing, AutoPay notice, or policy document routed to Finance/Bills.', + 'sender+sub::chase.com|discover.com::::creditwise|creditkarma|experian|equifax|transunion|myfico|capitalone::credit score|score went up|score went down|creditwise|score changed|credit alert|credit monitoring|credit report|fico score|new credit inquiry::::02_Finance_Legal::Finance/Banking::Updates::archive::0.99::Credit Score Alert::Automated credit score update or credit monitoring notification routed to Updates.', + 'custom:bankRate::::::::::::02_Finance_Legal::Finance/Banking::Updates::archive::0.99::Bank Rate Update::Automated bank account APY or interest rate change notification routed to Finance/Banking.', + 'any::::::::terms of service|privacy policy|terms and conditions|terms of use|user agreement|privacy notice|policy update|updates to our terms|updates to our privacy|arbitration clause|arbitration terms|consumer privacy::::::::Updates::archive::0.99::Policy / Terms Update::Commercial platform terms of service, privacy policy revision, or user agreement update routed to Updates.', + 'sender+sub::paypal.com::::::receipt|payment|transaction|you sent|you paid|you received|authorized|usd::::02_Finance_Legal::Finance/Purchases::Updates::archive::0.98::Payment Receipt::Electronic payment transaction receipt or transfer confirmation.', + 'sender+sub::homedepot.com::::::order|receipt|picked up|ready for pickup::::02_Finance_Legal::Finance/Purchases::Updates::archive::0.98::Retail Order / Receipt::Home Depot store receipt or order pickup confirmation.', + 'sender+sub::target.com|costco.com|bestbuy.com|walmart.com::::::order|receipt|ready for pickup|picked up|shipped|delivered::::02_Finance_Legal::Finance/Purchases::Updates::archive::0.98::Retail Order / Receipt::Retail store order confirmation, receipt, or pickup notice.', + 'sender+sub::ereceiptshell.com|autozone.com|advanceautoparts.com|rockauto.com::::shell e-receipt::receipt|purchase|order|fuel::::03_Vehicles::Vehicles/Maintenance::Updates::archive::0.99::Vehicle Fuel/Maintenance Receipt::Automated vehicle fuel purchase receipt or parts order confirmation routed to Vehicles/Maintenance.', + 'any::nytimes.com|nextdoor.com|substack.com|redditmail.com|daily.therundown.ai|therundown.ai|mail.instagram.com|instagram.com|facebookmail.com|linkedin.com|pinterest.com|quora.com|youtube.com|twitter.com|x.com::::dougforalabama|ossoff|the passion translation|aibreakfast::::::::::Social::archive::0.98::News/Newsletter/Social::Routine media newsletter, Substack digest, social media alert, or political appeal.', +] + +function evaluateDeterministicRuleSpec_(specStr, ctx, subject) { + const parts = specStr.split('::') + const mode = parts[0] + const excludeSubject = parts[5] + if (excludeSubject && containsAny_(ctx.subLower, excludeSubject)) { + return null + } + let matched = false + if (mode.indexOf('custom:') === 0) { + matched = CUSTOM_RULE_MATCHERS_[mode.slice(7)](ctx) + } else { + const senderHit = + matchesSenderDomain_(ctx.sender, parts[1]) || + matchesSenderAddress_(ctx.sender, parts[2]) || + containsAny_(ctx.sLower, parts[3]) + const subjectHit = containsAny_(ctx.subLower, parts[4]) + matched = + mode === 'sender+sub' ? senderHit && subjectHit : senderHit || subjectHit + } + if (!matched) return null + return makeDeterministicResult_( + parts[6] || null, + parts[7] || null, + parts[8], + parts[9], + Number.parseFloat(parts[10]), + subject, + parts[12], + parts[11] + ) +} + +/** + * Fast-path deterministic rule-based classifier. + * Categorizes unambiguous broadcast feeds, machine telemetry, routine receipts, + * and social newsletters in 0ms without consuming Gemini API quota. + * Strictly respects safety shields (user-sent, family, and 1:1 teacher correspondence). + * + * @param {string} sender - Email sender header + * @param {string} subject - Email subject line + * @param {string} snippet - Email body snippet + * @param {Object} [thread] - GmailThread object (for sender/reply safety checks) + * @param {Object} [config] - Classifier configuration + * @returns {Object|null} Classification object or null if email requires Gemini LLM reasoning + */ +function deterministicRuleClassify_(sender, subject, snippet, thread, config) { + const sLower = (sender || '').toLowerCase() + const subLower = (subject || '').toLowerCase() + const snipLower = (snippet || '').toLowerCase() + const userEmail = ( + config && config.userAccountEmail ? config.userAccountEmail : '' + ).toLowerCase() + + // 1. Safety Shields + if (thread && typeof thread.getMessages === 'function' && userEmail) { + try { + const msgs = thread.getMessages() || [] + for (let m = 0; m < msgs.length; m++) { + const fromA = ( + msgs[m] && typeof msgs[m].getFrom === 'function' + ? msgs[m].getFrom() + : (msgs[m] && msgs[m].from) || '' + ).toLowerCase() + if (fromA.indexOf(userEmail) !== -1) { + return makeDeterministicResult_( + null, + null, + 'Primary', + 'keep', + 1.0, + subject, + 'User-sent or replied thread preserved in Primary Inbox.', + 'User-Sent Shield' + ) + } + } + } catch { + // ignore thread check errors + } + } + + // Family 1:1 Shield (configurable via config.familyEmails or customPromptRules) + const familyList = + config && config.familyEmails + ? config.familyEmails.toLowerCase().split(',') + : [] + if ( + familyList.length > 0 && + familyList.some(function (f) { + return f.trim() && sLower.indexOf(f.trim()) !== -1 + }) + ) { + return makeDeterministicResult_( + '04_Family_Health', + 'Family/Correspondence', + 'Primary', + 'keep', + 1.0, + subject, + 'Direct family correspondence preserved in Primary Inbox.', + 'Family 1:1 Shield' + ) + } + + // Teacher / Academic Counselor 1:1 Shield (exclude mass broadcast subjects) + const isSchoolDomain = matchesSenderDomain_(sender, SCHOOL_DOMAINS_) + const isBrightArrow = + matchesSenderDomain_(sender, ['brightarrow.com']) || + sLower.indexOf('brightarrow') !== -1 + const isBroadcastSubject = containsAny_(subLower, SCHOOL_BROADCAST_TERMS_) + + if (isSchoolDomain && !isBroadcastSubject && !isBrightArrow) { + return makeDeterministicResult_( + '04_Family_Health', + 'Family/School-Student', + 'Primary', + 'keep', + 1.0, + subject, + 'Direct 1:1 teacher or school counselor correspondence preserved in Primary Inbox.', + 'Teacher 1:1 Shield' + ) + } + + // 06_Work_Career: Employment Agreements, Offer Letters, Employment Contracts, Recruiter Screens & Interviews + if ( + containsAny_(subLower, CAREER_SUBJECT_TERMS_) || + containsAny_(snipLower, CAREER_SNIPPET_TERMS_) + ) { + return makeDeterministicResult_( + '06_Work_Career', + 'Work/Career', + 'Primary', + 'keep', + 0.99, + subject, + 'Employment agreement, offer letter, or career interview communication preserved in Primary Inbox.', + 'Career / Employment Contract Rule' + ) + } + + // 2. Deterministic Feeds & Broadcasts + const ruleContext = { + sender: sender, + sLower: sLower, + subLower: subLower, + snipLower: snipLower, + isSchoolDomain: isSchoolDomain, + isBrightArrow: isBrightArrow, + isBroadcastSubject: isBroadcastSubject, + } + for (let r = 0; r < DETERMINISTIC_RULE_SPECS_.length; r++) { + const ruleHit = evaluateDeterministicRuleSpec_( + DETERMINISTIC_RULE_SPECS_[r], + ruleContext, + subject + ) + if (ruleHit) return ruleHit + } + + // Retail & E-Commerce Marketing Blasts + const isScarsella = sLower.indexOf('scarsella') !== -1 + const isScarsellaCurriculum = + isScarsella && + /\b(curriculum|schedule|testing|belt|forms?)\b/i.test( + (subject || '') + ' ' + (snippet || '') + ) + + const isPromoSubject = + /\b(sale|\d+%\s*off|deal of the day|daily deals?|clearance|limited time offer|coupon|promo code|shop now|shop today|exclusive offer|special offer|flash sale|black friday|cyber monday|memorial day sale|labor day sale|presidents day sale|discount|save \$\d+|save up to|free shipping|buy one get one|bogo|don't miss out|last chance to save|doorbusters?|rewards balance|earn bonus points|member exclusive|weekly ad|new arrivals)\b/i.test( + (subject || '') + ' ' + (snippet || '') + ) + const isMarketingSender = + /\b(promotions|promo|deals|marketing|offers|newsletter|news)@/i.test(sLower) + const isNonTransactional = !containsAny_( + subLower, + 'order|receipt|invoice|statement|autopay|tracking|shipped|delivered|action required|verify|security|support|case|ticket|return|refund|appointment|recall|warranty' + ) + + const isRetailVendor = + matchesSenderDomain_( + sender, + 'target.com|costco.com|bestbuy.com|walmart.com|doordash.com|kohls.com|samsclub.com|em1.cloudflare.com' + ) || + containsAny_( + sLower, + 'lowes|mannlake|urotuning|ray-ban|americanmeadows|foxhound|bhphotovideo|starbucks|birminghamhomebuyers|trailerparts|hillco' + ) + + if ( + isNonTransactional && + (isPromoSubject || + (isRetailVendor && isMarketingSender) || + (isScarsella && !isScarsellaCurriculum)) + ) { + return makeDeterministicResult_( + null, + null, + 'Promotions', + 'archive', + 0.98, + subject, + 'Commercial vendor retail promo, discount, or marketing announcement.', + 'Retail Promo' + ) + } + + return null +} + +/** + * Processes a batch of Gmail threads: classify, label, and optionally create filters. + * + * @param {Object[]} threads - Array of GmailThread objects + * @param {Object} config - Classifier configuration + * @param {Object} services - Injected GAS services: { GmailApp, UrlFetchApp, Gmail } + * @returns {Object[]} Array of result objects describing outcome per thread + */ +function processThreadBatch(threads, config, services) { + const processedLabel = ensureGmailLabel( + config.processedLabel, + services.GmailApp + ) + if (!processedLabel) { + console.error( + '[processThreadBatch] Processed label unavailable; aborting batch to avoid reprocessing.' + ) + return [] + } + const results = [] + + threads.forEach(function (thread) { + const threadId = + typeof thread.getId === 'function' + ? thread.getId() + : thread.id || 'unknown' + const messages = + typeof thread.getMessages === 'function' ? thread.getMessages() : [] + if (!messages || messages.length === 0) { + results.push({ threadId: threadId, status: 'empty' }) + return + } + + const latestMsg = messages[0] + const sender = + typeof latestMsg.getFrom === 'function' + ? latestMsg.getFrom() + : latestMsg.from || '' + const subject = + typeof latestMsg.getSubject === 'function' + ? latestMsg.getSubject() + : latestMsg.subject || '' + let bodySnippet = + typeof latestMsg.getPlainBody === 'function' + ? latestMsg.getPlainBody().substring(0, 1200) + : latestMsg.snippet || '' + bodySnippet = enrichSnippetWithAttachments_(bodySnippet, latestMsg) + + let classification = deterministicRuleClassify_( + sender, + subject, + bodySnippet, + thread, + config + ) + if (!classification) { + classification = classifyEmailWithGemini( + config, + sender, + subject, + bodySnippet, + services.UrlFetchApp + ) + } + if (!classification) { + console.log('[processThreadBatch] Could not classify thread:', threadId) + results.push({ threadId: threadId, status: 'unclassified' }) + return + } + + console.log( + '[processThreadBatch] Classified as:', + classification.canonical_label, + '(Confidence:', + classification.confidence, + ')' + ) + + var primaryTag = classification.subLabel + if (!primaryTag || isRootDomainLabel(primaryTag)) { + var dom = classification.canonicalDomain || classification.canonical_label + primaryTag = (dom && DEFAULT_DOMAIN_SUBLABEL_MAP[dom]) || dom || null + } + + if (typeof cleanConflictingLabels === 'function') { + cleanConflictingLabels( + thread, + classification.canonicalDomain || classification.canonical_label, + primaryTag, + config + ) + } + + if (primaryTag) { + const categoryLabel = ensureGmailLabel( + primaryTag, + services.GmailApp, + services.Gmail + ) + if (!categoryLabel) { + console.error( + '[processThreadBatch] Failed to create category label:', + primaryTag + ) + results.push({ + threadId: threadId, + status: 'label_creation_failed', + }) + return + } + thread.addLabel(categoryLabel) + } + thread.addLabel(processedLabel) + + // Category-Driven Action Invariant: + // Primary belongs in the Action Queue (Inbox). + // Non-Primary categories (Updates, Promotions, Social, Forums) are Informational Streams + // that deterministically bypass the Primary Inbox into their category tab and domain sub-label. + if ( + classification.category && + classification.category !== 'Primary' && + classification.action !== 'trash' + ) { + classification.action = 'archive' + } + + if ( + classification.category && + classification.action !== 'trash' && + typeof setGmailCategoryTab === 'function' + ) { + setGmailCategoryTab(thread, classification.category, services.Gmail) + } + + if ( + classification.action === 'archive' && + typeof thread.moveToArchive === 'function' + ) { + thread.moveToArchive() + } + + let filterCreated = false + if (classification.confidence >= config.autoFilterConfidenceThreshold) { + filterCreated = createPermanentGmailFilter( + sender, + classification.canonical_label, + services.Gmail, + services.GmailApp + ) + } + + let savedAttachments = [] + if (services && services.DriveApp) { + savedAttachments = persistCanonicalAttachmentsToDrive( + thread, + classification, + config, + services + ) + } + + results.push({ + threadId: threadId, status: 'classified', label: classification.canonical_label, confidence: classification.confidence, filterCreated: filterCreated, + savedAttachments: savedAttachments, }) }) return results } -module.exports = { - validateClassification, - classifyEmailWithGemini, - ensureGmailLabel, - createPermanentGmailFilter, - processThreadBatch, +const RULE6_PATTERNS = [ + [/\S \? \S/, "' ? ' between words (was an em dash or a · separator)"], + [/\?\?/, "'??' (was a multi-codepoint emoji)"], + [/[A-Za-z]\?[A-Za-z]/, "'?' inside a word (was a curly apostrophe)"], + [/\uFFFD/, 'U+FFFD replacement character'], +] + +/** + * Resolves the target GitHub repository owner and name from injected services, + * environment variables, ScriptProperties, or config. + * + * @param {Object} [services] - Optional injected services + * @returns {{ repoOwner: string, repoName: string }} + */ +function resolveGitHubRepoTarget_(services) { + const props = + services?.PropertiesService || + (typeof PropertiesService !== 'undefined' ? PropertiesService : null) + const scriptProps = props?.getScriptProperties?.() + const cfg = + typeof getAiClassifierConfig === 'function' ? getAiClassifierConfig() : {} + const envOwner = + typeof process !== 'undefined' && process.env + ? process.env.GITHUB_REPO_OWNER + : '' + const envName = + typeof process !== 'undefined' && process.env + ? process.env.GITHUB_REPO_NAME + : '' + + const repoOwner = + services?.githubRepoOwner || + envOwner || + scriptProps?.getProperty?.('GITHUB_REPO_OWNER') || + cfg.githubRepoOwner || + '' + const repoName = + services?.githubRepoName || + envName || + scriptProps?.getProperty?.('GITHUB_REPO_NAME') || + cfg.githubRepoName || + '' + + return { repoOwner, repoName } +} + +/** Refuse to write an entry that already shows mojibake. */ +function assertClean_(text, what) { + if (!text) return + const scrubbed = String(text).replace(/https?:\/\/\S+/gi, '') + for (let i = 0; i < RULE6_PATTERNS.length; i++) { + if (RULE6_PATTERNS[i][0].test(scrubbed)) { + throw new Error( + 'Rule 6: refusing to write ' + what + ' — ' + RULE6_PATTERNS[i][1] + ) + } + } +} + +/** Refuse to write when a non-ASCII char in the source text became '?' on the way out. */ +function assertNoAsciiReplacement_(source, rendered) { + if (!source || !rendered) return + if (rendered.indexOf('?') === -1) return + const lost = [] + for (let i = 0; i < source.length; i++) { + const c = source.charAt(i) + if ( + c.charCodeAt(0) > 127 && + rendered.indexOf(c) === -1 && + lost.indexOf(c) === -1 + ) { + lost.push(c) + } + } + if (lost.length) { + throw new Error( + 'Rule 6: refusing to write text that flattened non-ASCII to "?": ' + + lost.join(' ') + + ' — encode as UTF-8, not ASCII.' + ) + } +} + +function extractTopicTitleFromPath(filePath) { + if (!filePath || typeof filePath !== 'string') { + return 'Ingested Records' + } + const trimmed = filePath.trim() + if (!trimmed || trimmed === '/') { + return 'Ingested Records' + } + const parts = trimmed.split('/').filter(Boolean) + if (parts.length === 0) { + return 'Ingested Records' + } + const topic = parts.length > 1 ? parts[parts.length - 2] : parts[0] + // Strip any trailing file extension (e.g. ".md") before title-casing so a + // single-segment path like "digital-backups.md" becomes "Digital Backups". + const cleaned = topic.replace(/\.[^.]+$/, '').replace(/-/g, ' ') + if (!cleaned) return 'Ingested Records' + return cleaned.replace(/\b\w/g, function (l) { + return l.toUpperCase() + }) +} + +function insertEntryIntoLogSection(fullContent, newEntry) { + if (!fullContent && !newEntry) return '' + if (!fullContent) return String(newEntry) + if (!newEntry) return String(fullContent) + + const detailsMarker = '</details>' + const detailsIndex = fullContent.indexOf(detailsMarker) + + if (detailsIndex !== -1) { + return ( + fullContent.substring(0, detailsIndex) + + newEntry + + '\n' + + fullContent.substring(detailsIndex) + ) + } + + const section3Marker = '## 3. Ingested Activity' + const section3Index = fullContent.indexOf(section3Marker) + + if (section3Index !== -1) { + const lineBreakIndex = fullContent.indexOf('\n', section3Index) + return ( + fullContent.substring(0, lineBreakIndex + 1) + + newEntry + + '\n' + + fullContent.substring(lineBreakIndex + 1) + ) + } + + return fullContent + '\n' + newEntry +} + +function formatProgressiveDisclosureEntry( + dateStr, + title, + sender, + subject, + summaryText, + accountEmail, + attachments +) { + let entry = '\n### ' + dateStr + ' — ' + title + '\n' + entry += '- **Account**: ' + accountEmail + '\n' + entry += '- **From**: ' + sender + '\n' + entry += '- **Subject**: ' + subject + '\n' + if (summaryText) { + entry += '- **Summary**:\n > ' + summaryText.trim() + '\n' + } + if (attachments && attachments.length > 0) { + entry += '- **Attachments**:\n' + for (let a = 0; a < attachments.length; a++) { + const att = attachments[a] + if (att?.name) { + if (att.url) { + entry += ' - [' + att.name + '](' + att.url + ')\n' + } else { + entry += ' - ' + att.name + '\n' + } + } + } + } + return entry +} + +function getNotePathForDomain(domain, _subLabel) { + const map = { + '01_Household': '01_Household/index.md', + '02_Finance_Legal': '02_Finance_Legal/index.md', + '03_Vehicles': '03_Vehicles/index.md', + '04_Family_Health': '04_Family_Health/index.md', + '05_Tech_Infrastructure': '05_Tech_Infrastructure/index.md', + '06_Work_Career': '06_Work_Career/index.md', + '07_Community_NonProfit': '07_Community_NonProfit/index.md', + } + if (typeof PropertiesService !== 'undefined') { + try { + const customMapJson = + PropertiesService.getScriptProperties().getProperty('CUSTOM_NOTE_PATHS') + if (customMapJson) { + const customMap = JSON.parse(customMapJson) + if (customMap && customMap[domain]) { + return customMap[domain] + } + } + } catch { + // Fall through to default map + } + } + return map[domain] || null +} + +function appendMarkdownEntryToGitHubRepo( + filePath, + entryMd, + commitMessage, + services +) { + const props = + services?.PropertiesService || + (typeof PropertiesService !== 'undefined' ? PropertiesService : null) + const githubToken = props?.getScriptProperties()?.getProperty('GITHUB_PAT') + if (!githubToken) { + console.log( + '[gitHubSync] GITHUB_PAT ScriptProperty not set. Skipping GitHub commit.' + ) + return false + } + + const sleep = services?.sleepFn || _sleep + const maxRetries = 3 + for (let attempt = 1; attempt <= maxRetries; attempt++) { + const result = executeGitHubCommit( + filePath, + entryMd, + commitMessage, + githubToken, + services + ) + if (result === true || result === 'IDEMPOTENT_SKIP') { + return true + } + console.log( + '[gitHubSync] Retry attempt', + attempt, + 'of', + maxRetries, + 'for', + filePath + ) + sleep(1000 * attempt) + } + + console.error( + '[gitHubSync] Failed to commit entry to GitHub after', + maxRetries, + 'attempts:', + filePath + ) + return false +} + +function executeGitHubCommit( + filePath, + entryMd, + commitMessage, + githubToken, + services +) { + if (!filePath || !entryMd) { + console.warn('[gitHubSync] Missing filePath or entryMd for GitHub commit.') + return false + } + const urlFetchApp = + services?.UrlFetchApp || + (typeof UrlFetchApp !== 'undefined' ? UrlFetchApp : null) + const utils = + services?.Utilities || (typeof Utilities !== 'undefined' ? Utilities : null) + + const { repoOwner, repoName } = resolveGitHubRepoTarget_(services) + if (!repoOwner || !repoName) { + console.warn( + '[gitHubSync] GITHUB_REPO_OWNER or GITHUB_REPO_NAME not configured. Skipping GitHub commit.' + ) + return false + } + + const encodedPath = String(filePath) + .split('/') + .filter(Boolean) + .map(function (segment) { + return encodeURIComponent(segment) + }) + .join('/') + + const url = + 'https://api.github.com/repos/' + + encodeURIComponent(repoOwner) + + '/' + + encodeURIComponent(repoName) + + '/contents/' + + encodedPath + const headers = { + Authorization: 'Bearer ' + githubToken, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'Google-Apps-Script', + } + + try { + const getOptions = { + method: 'get', + headers: headers, + muteHttpExceptions: true, + } + const res = urlFetchApp.fetch(url, getOptions) + const statusCode = res.getResponseCode() + + let sha = null + let rawContent = '' + + if (statusCode === 404) { + console.log( + '[gitHubSync] File not found on GitHub (HTTP 404). Initializing new note:', + filePath + ) + const topicTitle = extractTopicTitleFromPath(filePath) + const dateStr = utils?.formatDate + ? utils.formatDate(new Date(), 'GMT', 'yyyy-MM-dd') + : new Date().toISOString().slice(0, 10) + rawContent = + '---\ntitle: ' + + topicTitle + + '\ncreated: ' + + dateStr + + '\nnotebook: household\nsection: general\n---\n\n' + + '# ' + + topicTitle + + '\n\n' + + '## 1. Executive Summary & Active Status\n- Ingested records log.\n\n' + + '## 2. Key References & Quick Links\n| Topic | Asset |\n| :--- | :--- |\n\n' + + '## 3. Ingested Activity Logs\n<details open><summary><b>Activity Logs</b></summary>\n</details>\n' + } else if (statusCode === 200) { + const fileData = JSON.parse(res.getContentText()) + sha = fileData.sha + // GitHub Contents API wraps Base64 in newlines every 60 chars; strip them + // before decoding since some decoders reject embedded whitespace. + rawContent = utils + .newBlob( + utils.base64Decode((fileData.content || '').replace(/[\r\n]/g, '')) + ) + .getDataAsString() + + if ( + rawContent.indexOf(entryMd.trim()) !== -1 || + (commitMessage && rawContent.indexOf(commitMessage) !== -1) + ) { + console.log( + '[gitHubSync] Idempotent Skip: Entry already exists in', + filePath + ) + return 'IDEMPOTENT_SKIP' + } + } else { + console.error( + '[gitHubSync] Error fetching file from GitHub (HTTP ' + + statusCode + + '):', + res.getContentText() + ) + return false + } + + const updatedContent = insertEntryIntoLogSection(rawContent, entryMd) + + assertClean_(entryMd, 'new entry for ' + filePath) + + const base64Updated = utils.base64Encode( + utils.newBlob(updatedContent).getBytes() + ) + + // Validate the ACTUAL Base64 round trip at the payload boundary: decode what + // we are about to PUT and confirm no non-ASCII character was flattened to + // '?'. Comparing updatedContent to entryMd/rawContent directly is inert + // because updatedContent contains both source strings unchanged. + const renderedContent = utils + .newBlob(utils.base64Decode(base64Updated)) + .getDataAsString() + assertNoAsciiReplacement_(updatedContent, renderedContent) + + const putPayload = { + message: + commitMessage || + 'feat(ingestion): append ingested document entry via Google Apps Script', + content: base64Updated, + branch: 'main', + } + if (sha) { + putPayload.sha = sha + } + + const putOptions = { + method: 'put', + headers: headers, + contentType: 'application/json', + payload: JSON.stringify(putPayload), + muteHttpExceptions: true, + } + + const putRes = urlFetchApp.fetch(url, putOptions) + const putStatus = putRes.getResponseCode() + + if (putStatus === 200 || putStatus === 201) { + console.log( + '[gitHubSync] Successfully committed markdown entry to GitHub:', + filePath + ) + return true + } else if (putStatus === 409) { + console.warn('[gitHubSync] SHA collision (HTTP 409) on file:', filePath) + return false + } else { + console.error( + '[gitHubSync] Error committing to GitHub (HTTP ' + putStatus + '):', + putRes.getContentText() + ) + return false + } + } catch (e) { + console.error('[gitHubSync] Exception calling GitHub API:', e.message) + return false + } +} + +function formatAuditDate_(d) { + const y = d.getFullYear() + const m = ('0' + (d.getMonth() + 1)).slice(-2) + const da = ('0' + d.getDate()).slice(-2) + return y + '/' + m + '/' + da +} + +function getSearchDateRange_(dateStr, days) { + const parts = dateStr.split('-') + const year = Number.parseInt(parts[0], 10) + const month = Number.parseInt(parts[1], 10) - 1 + const day = Number.parseInt(parts[2], 10) + const dt = new Date(year, month, day) + + const beforeDt = new Date(dt.getTime() + (days + 1) * 86400000) + const afterDt = new Date(dt.getTime() - days * 86400000) + + return { + after: formatAuditDate_(afterDt), + before: formatAuditDate_(beforeDt), + } +} + +/** + * Checks whether a sender matches known non-canonical newsletter or social domains. + * + * @param {string} sender - Raw sender header + * @returns {boolean} True if sender is a non-canonical newsletter/social source + */ +function isNonCanonicalSender_(sender) { + return ( + matchesSenderDomain_(sender, [ + 'substack.com', + 'redditmail.com', + 'nextdoor.com', + 'facebookmail.com', + 'instagram.com', + 'twitter.com', + 'x.com', + 'pinterest.com', + 'quora.com', + ]) || /\bnewsletters?@/i.test(String(sender || '')) + ) +} + +/** + * Checks whether a thread has been assigned a secondary Gmail category tab + * (Updates, Promotions, Social, Forums) via user labels, thread properties, + * or the Advanced Gmail API (`CATEGORY_*` system label IDs). + * + * @param {Object} thread - GmailThread or mock object + * @param {string[]} rawLabels - User label names on the thread + * @param {Object} [services] - Optional injected services ({ Gmail }) + * @returns {boolean} True if thread is in a secondary category stream + */ +function hasSecondaryCategory_(thread, rawLabels, services) { + if ( + Array.isArray(rawLabels) && + rawLabels.some(function (l) { + return /promotions|updates|social|forums|archive/i.test(String(l || '')) + }) + ) { + return true + } + + const threadCat = + typeof thread?.getCategory === 'function' + ? thread.getCategory() + : thread?.category || '' + if ( + threadCat && + /promotions|updates|social|forums|CATEGORY_(UPDATES|PROMOTIONS|SOCIAL|FORUMS)/i.test( + String(threadCat) + ) + ) { + return true + } + + const gmailAdv = + services?.Gmail || (typeof Gmail !== 'undefined' ? Gmail : null) + const threadId = + typeof thread?.getId === 'function' ? thread.getId() : thread?.id || '' + if (threadId && typeof gmailAdv?.Users?.Threads?.get === 'function') { + try { + const threadMeta = gmailAdv.Users.Threads.get('me', threadId) + const labelIds = [].concat( + Array.isArray(threadMeta?.labelIds) ? threadMeta.labelIds : [] + ) + if (Array.isArray(threadMeta?.messages)) { + for (let m = 0; m < threadMeta.messages.length; m++) { + if (Array.isArray(threadMeta.messages[m]?.labelIds)) { + labelIds.push(...threadMeta.messages[m].labelIds) + } + } + } + if ( + labelIds.some(function (id) { + return /^CATEGORY_(UPDATES|PROMOTIONS|SOCIAL|FORUMS)$/i.test( + String(id || '') + ) + }) + ) { + return true + } + } catch { + // Non-blocking fallback when Advanced Gmail API is unavailable + } + } + + return false +} + +/** + * Audits a collection of processed Gmail threads for classification anomalies. + * + * @param {Array} threads - Array of GmailThread-like objects + * @param {Object} config - Classifier config + * @param {Object} [services] - Optional injected services ({ Gmail }) + * @returns {Object} report - { scannedCount, flaggedCount, findings, summary } + */ +function auditClassifications(threads, config, services) { + const canonicalDomains = config?.canonicalDomains || [ + '01_Household', + '02_Finance_Legal', + '03_Vehicles', + '04_Family_Health', + '05_Tech_Infrastructure', + '06_Work_Career', + '07_Community_NonProfit', + ] + + const PROMO_KEYWORDS = + /\b(sale|\d+% off|deal of the day|clearance|limited time offer|coupon|shop now)\b/i + const NEWSLETTER_KEYWORDS = + /\b(weekly digest|daily digest|newsletter|roundup|top stories)\b/i + const ORDER_KEYWORDS = + /\b(order confirmation|your order|receipt|payment received|invoice|shipped)\b/i + const TOS_KEYWORDS = + /\b(terms of service|privacy policy|terms and conditions|user agreement|arbitration terms)\b/i + const SURVEY_KEYWORDS = + /\b(survey|feedback|net promoter|nps|rate your experience|how did we do|review us)\b/i + const POLITICAL_KEYWORDS = + /\b(campaign|pac|fundraiser|election|candidate|contribute now|donate to our campaign)\b/i + + const findings = [] + + if (!Array.isArray(threads)) { + return { + scannedCount: 0, + flaggedCount: 0, + findings: [], + summary: 'No threads to audit.', + } + } + + threads.forEach((thread) => { + if (!thread) return + const id = + typeof thread.getId === 'function' ? thread.getId() : thread.id || '' + const subject = + typeof thread.getFirstMessageSubject === 'function' + ? thread.getFirstMessageSubject() + : thread.subject || '' + + let sender = '' + if (typeof thread.getMessages === 'function') { + const msgs = thread.getMessages() + if (msgs && msgs.length > 0 && typeof msgs[0].getFrom === 'function') { + sender = msgs[0].getFrom() + } + } else if (thread.sender) { + sender = thread.sender + } + + let rawLabels = [] + if (typeof thread.getLabels === 'function') { + const labelObjs = thread.getLabels() || [] + rawLabels = labelObjs.map((l) => + typeof l.getName === 'function' ? l.getName() : String(l) + ) + } else if (Array.isArray(thread.labels)) { + rawLabels = thread.labels + } + + const assignedCanonical = [] + rawLabels.forEach((l) => { + if (!l || typeof l !== 'string') return + const resolved = resolveCanonicalDomainFromLabels([l], canonicalDomains) + if (resolved && canonicalDomains.includes(resolved)) { + if (!assignedCanonical.includes(resolved)) { + assignedCanonical.push(resolved) + } + return + } + const match = l.match(/^(0[1-7]_[A-Za-z_]+)/) + if (match && canonicalDomains.includes(match[1])) { + if (!assignedCanonical.includes(match[1])) { + assignedCanonical.push(match[1]) + } + } + }) + + let isInTrash = false + if (typeof thread.isInTrash === 'function') { + try { + isInTrash = thread.isInTrash() + } catch {} + } else if (thread.isInTrash === true || thread.isTrash === true) { + isInTrash = true + } + + const flags = [] + const fullText = (subject + ' ' + sender).toLowerCase() + const isReceiptOrOrder = ORDER_KEYWORDS.test(fullText) + const isNonCanonicalStream = + isInTrash || + (!isReceiptOrOrder && + (rawLabels.some((l) => + /promotions|social|forums|marketing|promo|deal|newsletter/i.test(l) + ) || + isNonCanonicalSender_(sender) || + PROMO_KEYWORDS.test(subject) || + NEWSLETTER_KEYWORDS.test(subject) || + TOS_KEYWORDS.test(subject) || + SURVEY_KEYWORDS.test(subject) || + POLITICAL_KEYWORDS.test(subject))) + + // 1. Missing Domain + if (assignedCanonical.length === 0 && !isNonCanonicalStream) { + flags.push( + 'MISSING_CANONICAL_DOMAIN: Thread marked as processed has no canonical domain label.' + ) + } + + // 2. Conflicting Domains + if (assignedCanonical.length > 1) { + flags.push( + 'CONFLICTING_DOMAINS: Multiple canonical domain labels assigned: ' + + assignedCanonical.join(', ') + ) + } + + // 3. Heuristic Checks + if (ORDER_KEYWORDS.test(fullText)) { + const hasFinanceOrHousehold = assignedCanonical.some( + (l) => + l.includes('02_Finance_Legal') || + l.includes('01_Household') || + l.includes('03_Vehicles') + ) + if (assignedCanonical.length > 0 && !hasFinanceOrHousehold) { + flags.push( + 'SUSPICIOUS_ROUTING: Purchase/receipt keywords detected but domain is ' + + assignedCanonical.join(', ') + + ' instead of 02_Finance_Legal.' + ) + } + } + + if (PROMO_KEYWORDS.test(subject) && assignedCanonical.length > 0) { + const hasMarketingSublabel = rawLabels.some((l) => + /promo|marketing|deal|coupon/i.test(l) + ) + if (!hasMarketingSublabel) { + flags.push( + 'PROMOTIONAL_CONTENT: Promotional sale keywords in subject tagged under core domain ' + + assignedCanonical.join(', ') + + ' without marketing sub-label.' + ) + } + } + + if (NEWSLETTER_KEYWORDS.test(subject) && assignedCanonical.length > 0) { + const hasNewsletterSublabel = rawLabels.some((l) => + /newsletter|digest|news/i.test(l) + ) + if (!hasNewsletterSublabel) { + flags.push( + 'UNLABELED_NEWSLETTER: Generic newsletter/digest subject tagged under core domain ' + + assignedCanonical.join(', ') + + ' without newsletter sub-label.' + ) + } + } + + if ( + TOS_KEYWORDS.test(subject) && + assignedCanonical.some((l) => l.includes('02_Finance_Legal')) + ) { + flags.push( + 'COMMERCIAL_TOS_IN_LEGAL: Routine commercial terms of service/privacy policy update tagged under 02_Finance_Legal instead of non-canonical broadcast.' + ) + } + + // 4. Drive Attachment Persistence & Tagging Check + const driveApp = + config?.driveApp || (typeof DriveApp !== 'undefined' ? DriveApp : null) + if ( + driveApp && + assignedCanonical.length === 1 && + typeof thread.getMessages === 'function' + ) { + const domain = assignedCanonical[0] + const subLabel = + rawLabels.find((l) => { + if ( + !l || + typeof l !== 'string' || + !l.includes('/') || + l.toLowerCase().startsWith('ai/') || + l.startsWith('Retention/') || + l.includes('Archives') + ) { + return false + } + if (/^0[1-7]_/.test(l)) { + return l.startsWith(domain + '/') + } + return true + }) || '' + const subfolderName = resolveTaxonomySubfolderName(domain, subLabel) + const msgs = thread.getMessages() || [] + msgs.forEach((msg) => { + const atts = getMessageAttachments_(msg) + atts.forEach((att) => { + if (isEligibleAttachment(att)) { + const attName = + typeof att.getName === 'function' + ? att.getName() + : att.name || 'unnamed' + try { + const targetFolder = findDriveTaxonomyFolder_( + domain, + subfolderName, + driveApp, + config?.driveRootFolderName + ) + const existingFiles = targetFolder + ? targetFolder.getFilesByName(attName) + : null + if ( + !existingFiles || + (typeof existingFiles.hasNext === 'function' && + !existingFiles.hasNext()) + ) { + flags.push( + 'MISSING_DRIVE_ATTACHMENT: Eligible attachment "' + + attName + + '" is missing from Drive folder "' + + domain + + '/' + + subfolderName + + '".' + ) + } else if (typeof existingFiles.next === 'function') { + const driveFile = existingFiles.next() + const desc = + typeof driveFile.getDescription === 'function' + ? driveFile.getDescription() + : '' + if (!desc || !desc.includes('[AI_INDEXED]')) { + flags.push( + 'UNTAGGED_DRIVE_ATTACHMENT: Attachment "' + + attName + + '" exists in Drive but lacks [AI_INDEXED] metadata description.' + ) + } + } + } catch { + // Non-blocking folder check + } + } + }) + }) + } + + // 5. Archived from Inbox Without Action Check + const isArchived = + (typeof thread.isInInbox === 'function' && + thread.isInInbox() === false) || + thread.isInInbox === false + if (isArchived && assignedCanonical.length > 0) { + const isStarred = + (typeof thread.isStarred === 'function' && + thread.isStarred() === true) || + thread.isStarred === true + const userEmail = (config && config.userAccountEmail) || '' + const isSentOrReplied = + typeof isThreadSentOrRepliedByUser === 'function' + ? isThreadSentOrRepliedByUser(thread, userEmail) + : false + const hasSecondaryCategory = hasSecondaryCategory_( + thread, + rawLabels, + services + ) + + if (!isStarred && !isSentOrReplied && !hasSecondaryCategory) { + flags.push( + 'ARCHIVED_WITHOUT_ACTION: Thread tagged under ' + + assignedCanonical.join(', ') + + ' was archived from Inbox without user reply or star; candidate for secondary stream routing (Updates).' + ) + } + } + + if (flags.length > 0) { + findings.push({ + threadId: id, + subject: subject, + sender: sender, + canonicalLabels: assignedCanonical, + allLabels: rawLabels, + flags: flags, + }) + } + }) + + return { + scannedCount: threads.length, + flaggedCount: findings.length, + findings: findings, + summary: + 'Audited ' + + threads.length + + ' thread(s); ' + + findings.length + + ' anomaly flag(s) identified.', + } +} + +/** + * Formats an audit report into a human-readable email digest or log message. + * + * @param {Object} report - Result from auditClassifications + * @returns {string} Formatted digest text + */ +function formatAuditDigest(report) { + if (!report || report.flaggedCount === 0) { + return ( + 'Gmail AI Classifier Audit: All ' + + (report ? report.scannedCount : 0) + + ' analyzed threads are compliant. No anomalies detected.' + ) + } + + let text = '===================================================\n' + text += ' GMAIL AI CLASSIFICATION AUDIT REPORT\n' + text += '===================================================\n' + text += report.summary + '\n\n' + + report.findings.forEach((finding, idx) => { + if (!finding) return + const labelsStr = + Array.isArray(finding.canonicalLabels) && + finding.canonicalLabels.length > 0 + ? finding.canonicalLabels.join(', ') + : '(none)' + const flagsList = Array.isArray(finding.flags) ? finding.flags : [] + + text += + idx + 1 + '. Subject: "' + (finding.subject || '(no subject)') + '"\n' + text += ' Sender: ' + (finding.sender || '(unknown)') + '\n' + text += ' Labels: ' + labelsStr + '\n' + text += ' Flags:\n' + flagsList.forEach((f) => { + text += ' • ' + f + '\n' + }) + text += '\n' + }) + + text += '---------------------------------------------------\n' + text += + 'Tuning Action: Update ScriptProperty CUSTOM_PROMPT_RULES to add calibrated sender rules.\n' + text += '===================================================\n' + return text +} + +/** + * Daily audit sweep that examines recently classified threads (default: last 2 days), + * identifies classification anomalies, logs them, and sends an email digest. + * + * @param {Object} [customConfig] - Optional override configuration + * @param {Object} [customServices] - Optional injected services (GmailApp) + * @returns {Object} report - { scannedCount, flaggedCount, findings, summary } + */ +function auditEmailClassifications(customConfig, customServices) { + console.log( + '[auditEmailClassifications] Starting daily classification audit sweep...' + ) + const services = customServices || {} + const config = resolveEffectiveConfig_(customConfig) + const gmail = + services.GmailApp || (typeof GmailApp !== 'undefined' ? GmailApp : null) + const lookback = config.auditLookbackDays || 2 + const query = + 'label:' + + (config.processedLabel || 'Processed') + + ' newer_than:' + + lookback + + 'd' + + const rawThreads = + gmail && typeof gmail.search === 'function' + ? gmail.search(query, 0, 50) + : [] + const threads = sortThreadsByLastMessageDate_(rawThreads) + console.log( + '[auditEmailClassifications] Found ' + + threads.length + + ' processed thread(s) in last ' + + lookback + + ' day(s).' + ) + + const report = auditClassifications(threads, config, services) + console.log('[auditEmailClassifications] ' + report.summary) + + if (report.flaggedCount > 0) { + const digest = formatAuditDigest(report) + console.warn('[auditEmailClassifications] Anomalies detected:\n' + digest) + + if ( + config.auditDigestEmail && + gmail && + typeof gmail.sendEmail === 'function' + ) { + try { + gmail.sendEmail( + config.auditDigestEmail, + '[Gmail AI Classifier] Daily Classification Audit: ' + + report.flaggedCount + + ' anomalies detected', + digest + ) + console.log( + '[auditEmailClassifications] Audit digest email sent to ' + + config.auditDigestEmail + ) + } catch (e) { + console.error( + '[auditEmailClassifications] Failed to send audit digest email:', + e.message + ) + } + } + } else { + console.log( + '[auditEmailClassifications] All analyzed threads are compliant with taxonomy rules.' + ) + } + + return report +} + +/** + * Sets up a weekly cloud trigger to run purgeExpiredEmailsByRetentionPolicy every Sunday at 1:00 AM. + */ +function setupWeeklyRetentionTrigger(services) { + const scriptApp = + services?.ScriptApp || (typeof ScriptApp !== 'undefined' ? ScriptApp : null) + if (!scriptApp) return + const triggers = scriptApp.getProjectTriggers() || [] + for (let i = 0; i < triggers.length; i++) { + if ( + triggers[i].getHandlerFunction() === 'purgeExpiredEmailsByRetentionPolicy' + ) { + scriptApp.deleteTrigger(triggers[i]) + } + } + + scriptApp + .newTrigger('purgeExpiredEmailsByRetentionPolicy') + .timeBased() + .onWeekDay(scriptApp.WeekDay ? scriptApp.WeekDay.SUNDAY : 1) + .atHour(1) + .create() + + console.log( + '[setupWeeklyRetentionTrigger] Established weekly Sunday 1:00 AM retention cleanup trigger.' + ) +} + +/** + * Sets up a daily cloud trigger to run auditEmailClassifications every morning at 6:00 AM. + */ +function setupDailyAuditTrigger(services) { + const scriptApp = + services?.ScriptApp || (typeof ScriptApp !== 'undefined' ? ScriptApp : null) + if (!scriptApp) return + const triggers = scriptApp.getProjectTriggers() || [] + for (let i = 0; i < triggers.length; i++) { + if (triggers[i].getHandlerFunction() === 'auditEmailClassifications') { + scriptApp.deleteTrigger(triggers[i]) + } + } + + scriptApp + .newTrigger('auditEmailClassifications') + .timeBased() + .everyDays(1) + .atHour(6) + .create() + + console.log( + '[setupDailyAuditTrigger] Established daily 6:00 AM classification audit trigger.' + ) +} + +/** + * Sets up a recurring cloud trigger to run auditEmailClassifications every 4 hours. + */ +function setupFourHourAuditTrigger(services) { + const scriptApp = + services?.ScriptApp || (typeof ScriptApp !== 'undefined' ? ScriptApp : null) + if (!scriptApp) return + const triggers = scriptApp.getProjectTriggers() || [] + for (let i = 0; i < triggers.length; i++) { + if (triggers[i].getHandlerFunction() === 'auditEmailClassifications') { + scriptApp.deleteTrigger(triggers[i]) + } + } + + scriptApp + .newTrigger('auditEmailClassifications') + .timeBased() + .everyHours(4) + .create() + + console.log( + '[setupFourHourAuditTrigger] Established 4-hour classification audit trigger.' + ) +} + +/** + * Clears all active time-driven triggers for this script. + */ +function stopAllTriggers(services) { + const scriptApp = + services?.ScriptApp || (typeof ScriptApp !== 'undefined' ? ScriptApp : null) + if (!scriptApp) return + const triggers = scriptApp.getProjectTriggers() || [] + for (let i = 0; i < triggers.length; i++) { + scriptApp.deleteTrigger(triggers[i]) + } + console.log('[stopAllTriggers] All script triggers removed.') +} + +/** + * Creates an automatic Cloud Trigger that runs email classification every 5 minutes 24/7. + */ +function setupFiveMinuteTrigger(services) { + const scriptApp = + services?.ScriptApp || (typeof ScriptApp !== 'undefined' ? ScriptApp : null) + if (!scriptApp) return + stopAllTriggers(services) + scriptApp + .newTrigger('processEmailsWithAiClassifier') + .timeBased() + .everyMinutes(5) + .create() + setupWeeklyRetentionTrigger(services) + setupDailyAuditTrigger(services) + console.log( + '[setupFiveMinuteTrigger] Successfully established 5-minute recurring cloud trigger, weekly retention trigger, and daily audit trigger.' + ) +} + +/** + * Decommissioned static filter creation helper (retained as no-op for backward compatibility). + */ +function createGmailFilterRule(_senderEmail, _targetLabelName) { + console.log( + '[createGmailFilterRule] Permanent filter auto-creation is permanently decommissioned. No-op.' + ) +} + +// --------------------------------------------------------------------------- +// Attachment Persistence to Google Drive along Taxonomy Path +// --------------------------------------------------------------------------- + +const CANONICAL_TAXONOMY_SUBFOLDERS = { + '01_Household': [ + 'Primary_House', + 'Shop_Build', + 'Rental_Property', + 'Archive_Property', + 'Bills', + 'Maintenance', + ], + '02_Finance_Legal': [ + 'Taxes', + 'Insurance', + 'Banking', + 'Legal_Court', + 'Estate_Planning', + 'Bills', + 'Purchases', + ], + '03_Vehicles': ['Car_Hunt', 'Vehicle_Fleet', 'Maintenance'], + '04_Family_Health': [ + 'Family_General', + 'Students', + 'Adults', + 'Medical_Records', + 'Activities_Camps', + ], + '05_Tech_Infrastructure': ['NAS_Backups', 'Tasker', 'Hardware_Licenses'], + '06_Work_Career': [ + 'Career_Interviews', + 'Career_DJ', + 'Career_Rachel', + 'Employer', + 'Expenses_Admin', + ], + '07_Community_NonProfit': ['Community_BOD', 'Projects_Telemetry'], +} + +const DEFAULT_DOMAIN_SUBLABEL_MAP = { + '01_Household': 'Household/General', + '02_Finance_Legal': 'Finance/Banking', + '03_Vehicles': 'Vehicles/General', + '04_Family_Health': 'Family/General', + '05_Tech_Infrastructure': 'Tech/Infrastructure', + '06_Work_Career': 'Work/General', + '07_Community_NonProfit': 'Community/NonProfit', +} + +function isRootDomainLabel(name) { + if (!name || typeof name !== 'string') return false + return ( + !name.includes('/') && + (/^0[1-7]_/.test(name) || + (typeof DEFAULT_DOMAIN_SUBLABEL_MAP !== 'undefined' && + Object.prototype.hasOwnProperty.call( + DEFAULT_DOMAIN_SUBLABEL_MAP, + name + ))) + ) +} + +const SUBLABEL_TO_FOLDER_MAP = { + // 01_Household + 'household/general': 'Primary_House', + 'household/property': 'Primary_House', + 'household/primary-property': 'Primary_House', + 'household/primary_house': 'Primary_House', + 'household/primary': 'Primary_House', + 'household/home-maintenance': 'Maintenance', + 'household/maintenance': 'Maintenance', + 'household/utilities': 'Bills', + 'household/bills': 'Bills', + 'household/shop': 'Shop_Build', + 'household/shop-build': 'Shop_Build', + 'household/travel': 'Primary_House', + + // 02_Finance_Legal + 'finance/banking': 'Banking', + 'finance/bills': 'Bills', + 'finance/taxes': 'Taxes', + 'finance/insurance': 'Insurance', + 'finance/purchases': 'Purchases', + 'finance/legal': 'Legal_Court', + 'finance/legal_court': 'Legal_Court', + 'finance/donations': 'Taxes', + 'finance/charitable-donations': 'Taxes', + 'finance/estate-planning': 'Estate_Planning', + 'finance/estate_planning': 'Estate_Planning', + + // 03_Vehicles + 'vehicles/general': 'Maintenance', + 'vehicles/maintenance': 'Maintenance', + 'vehicles/parts-orders': 'Maintenance', + 'vehicles/insurance': 'Insurance', + 'vehicles/registration': 'Maintenance', + 'vehicles/rental-cars': 'Maintenance', + 'vehicles/car-hunt': 'Car_Hunt', + 'vehicles/car_hunt': 'Car_Hunt', + + // 04_Family_Health + 'family/general': 'Family_General', + 'family/correspondence': 'Family_General', + 'family/personal-correspondence': 'Family_General', + 'family/legal': 'Family_General', + 'family/medical': 'Medical_Records', + 'family/medical-records': 'Medical_Records', + 'family/medical-student': 'Medical_Records', + 'family/health-general': 'Medical_Records', + 'family/school-student': 'Students', + 'family/school-charley': 'Students', + 'family/school-toby': 'Students', + 'family/school-tide': 'Students', + 'family/parents': 'Family_General', + 'family/kids/tide': 'Students', + 'family/kids/toby': 'Students', + 'family/kids/david': 'Students', + 'family/kids': 'Students', + 'family/kids/school-tide': 'Students', + 'family/kids/school-toby': 'Students', + 'family/kids/school': 'Students', + 'family/kids/tide-health': 'Medical_Records', + 'family/kids/toby-health': 'Medical_Records', + 'family/activities': 'Activities_Camps', + 'family/camps': 'Activities_Camps', + 'family/sisters': 'Family_General', + + // 05_Tech_Infrastructure + 'tech/infrastructure': 'Hardware_Licenses', + 'tech/cloud': 'NAS_Backups', + 'tech/security': 'Hardware_Licenses', + 'tech/alerts': 'NAS_Backups', + 'tech/alerts-monitoring': 'NAS_Backups', + 'tech/backups': 'NAS_Backups', + 'tech/hardware': 'Hardware_Licenses', + 'tech/hardware-licenses': 'Hardware_Licenses', + 'tech/cloud-gcp': 'NAS_Backups', + + // 06_Work_Career + 'work/general': 'Career_Interviews', + 'work/career': 'Career_Interviews', + 'work/employer': 'Career_Interviews', + 'career/dj': 'Career_DJ', + 'career/rachel': 'Career_Rachel', + 'career/employer': 'Employer', + 'work/notes': 'Career_Interviews', + 'work/expenses': 'Expenses_Admin', + 'work/expenses-admin': 'Expenses_Admin', + 'work/architecture': 'Career_Interviews', + + // 07_Community_NonProfit + 'community/nonprofit': 'Community_BOD', + 'nonprofit/general': 'Community_BOD', + 'projects/charity': 'Community_BOD', + 'community/bod': 'Community_BOD', + 'projects/telemetry': 'Projects_Telemetry', + 'projects/beekeeping': 'Projects_Telemetry', + 'projects/hog': 'Community_BOD', + 'community/nonprofit-bod': 'Community_BOD', + 'community/charity': 'Community_BOD', +} + +/** + * Validates whether an email classification represents a canonical domain. + * Non-canonical emails (promotions, newsletters, spam) return null/empty for canonicalDomain. + * + * @param {Object} classification - Gemini classification object + * @param {Object} [config] - Classifier configuration + * @returns {boolean} True if canonical domain, false if non-canonical or null + */ +function isCanonicalClassification(classification, config) { + if (!classification || typeof classification !== 'object') return false + const domain = + classification.canonicalDomain || classification.canonical_label + if (!domain || typeof domain !== 'string') return false + const trimmed = domain.trim() + if (trimmed === '' || trimmed === 'null' || trimmed === 'undefined') + return false + + const allowedDomains = config?.canonicalDomains || [ + '01_Household', + '02_Finance_Legal', + '03_Vehicles', + '04_Family_Health', + '05_Tech_Infrastructure', + '06_Work_Career', + '07_Community_NonProfit', + ] + + return allowedDomains.some( + (d) => trimmed === d || trimmed.startsWith(d + '/') + ) +} + +/** + * Resolves the 2nd-level Google Drive taxonomy subfolder name given a canonical domain and sub-label. + * + * @param {string} canonicalDomain - e.g. "02_Finance_Legal" + * @param {string} [subLabel] - e.g. "Finance/Banking" or "Bills" + * @returns {string} Subfolder name + */ +function resolveTaxonomySubfolderName(canonicalDomain, subLabel) { + if (subLabel && typeof subLabel === 'string') { + const normalized = subLabel.trim().toLowerCase() + if (SUBLABEL_TO_FOLDER_MAP[normalized]) { + return SUBLABEL_TO_FOLDER_MAP[normalized] + } + + if ( + normalized.startsWith('family/kids') || + normalized.startsWith('family/school') + ) { + if (normalized.includes('health') || normalized.includes('medical')) { + return 'Medical_Records' + } + return 'Students' + } + if ( + normalized.startsWith('family/sisters') || + normalized.startsWith('family/parents') + ) { + return 'Family_General' + } + + const parts = subLabel.split('/') + const subPart = (parts.length > 1 ? parts[1] : parts[0]).trim() + const sanitized = subPart.replace(/[-\s]+/g, '_') + + const knownSubfolders = CANONICAL_TAXONOMY_SUBFOLDERS[canonicalDomain] || [] + const match = knownSubfolders.find( + (sf) => sf.toLowerCase() === sanitized.toLowerCase() + ) + if (match) return match + + if (sanitized.length > 0) return sanitized + } + + const defaults = { + '01_Household': 'Primary_House', + '02_Finance_Legal': 'Banking', + '03_Vehicles': 'Maintenance', + '04_Family_Health': 'Medical_Records', + '05_Tech_Infrastructure': 'NAS_Backups', + '06_Work_Career': 'Career_Interviews', + '07_Community_NonProfit': 'Community_BOD', + } + return defaults[canonicalDomain] || 'General' +} + +/** + * Evaluates whether an email attachment is an eligible canonical document + * and filters out inline images, email signatures, tracking pixels, and non-document artifacts. + * + * @param {Object} att - Attachment blob or mock object + * @returns {{ eligible: boolean, reason: string }} + */ +function evaluateAttachmentEligibility(att) { + if (!att) return { eligible: false, reason: 'NULL_OR_EMPTY' } + const name = + typeof att.getName === 'function' ? att.getName() : att.name || '' + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return { eligible: false, reason: 'MISSING_NAME' } + } + + let size = 0 + if (typeof att.getSize === 'function') { + size = att.getSize() + } else if (typeof att.getBytes === 'function') { + const bytes = att.getBytes() + size = bytes ? bytes.length : 0 + } else if (att.bytes) { + size = att.bytes.length + } else if (att.size !== undefined) { + size = att.size + } + + // 1. Skip empty files or files exceeding GAS payload limit (30MB limit; cap at 25MB) + if (size <= 0) { + return { eligible: false, reason: 'ZERO_BYTE' } + } + if (size > 25 * 1024 * 1024) { + return { eligible: false, reason: 'EXCEEDS_GAS_SIZE_LIMIT (>25MB)' } + } + + const cleanName = name.trim() + const dotIndex = cleanName.lastIndexOf('.') + const ext = dotIndex > 0 ? cleanName.slice(dotIndex + 1).toLowerCase() : '' + const stem = ( + dotIndex > 0 ? cleanName.slice(0, dotIndex) : cleanName + ).toLowerCase() + const mimeType = ( + typeof att.getContentType === 'function' + ? att.getContentType() + : att.contentType || '' + ).toLowerCase() + + // 2. Unconditionally blocked non-document extensions + const BLOCKED_EXTENSIONS = new Set([ + 'ics', + 'ical', + 'ifb', + 'vcf', + 'vcard', + 'html', + 'htm', + 'css', + 'js', + 'mjs', + 'json', + 'xml', + 'rss', + 'p7s', + 'p7m', + 'p7c', + 'asc', + 'sig', + 'dat', + 'eml', + 'msg', + 'exe', + 'dmg', + 'pkg', + 'bin', + 'apk', + 'app', + 'sh', + 'bat', + 'cmd', + 'msi', + 'ttf', + 'woff', + 'woff2', + 'eot', + 'otf', + 'ico', + 'gif', + ]) + + if (BLOCKED_EXTENSIONS.has(ext)) { + return { eligible: false, reason: 'BLOCKED_EXTENSION:.' + ext } + } + + // 4. Canonical document types (Whitelisted) + const DOCUMENT_EXTENSIONS = new Set([ + 'pdf', + 'docx', + 'doc', + 'rtf', + 'odt', + 'pages', + 'xlsx', + 'xls', + 'csv', + 'tsv', + 'ods', + 'numbers', + 'pptx', + 'ppt', + 'key', + ]) + + if (DOCUMENT_EXTENSIONS.has(ext)) { + return { eligible: true, reason: 'CANONICAL_DOCUMENT' } + } + + // 5. Plain text files (.txt) - must be substantial and not boilerplate disclaimer + if (ext === 'txt') { + if (/^(disclaimer|signature|notice|footer|legal|terms)$/i.test(stem)) { + return { eligible: false, reason: 'TEXT_BOILERPLATE_DISCLAIMER' } + } + return { eligible: true, reason: 'CANONICAL_TEXT_DOCUMENT' } + } + + // 6. Archives (.zip) + if (ext === 'zip') { + return { eligible: true, reason: 'CANONICAL_ARCHIVE' } + } + + // 7. Image files (.jpg, .jpeg, .png, .heic, .tiff, .tif, .webp) + const IMAGE_EXTENSIONS = new Set([ + 'jpg', + 'jpeg', + 'png', + 'heic', + 'tiff', + 'tif', + 'webp', + ]) + + const isImage = IMAGE_EXTENSIONS.has(ext) || mimeType.startsWith('image/') + + if (isImage) { + // 7a. Stricter size threshold: genuine receipt/document photos are virtually always >= 35KB + if (size < 35 * 1024) { + return { eligible: false, reason: 'IMAGE_BELOW_SIZE_THRESHOLD (<35KB)' } + } + + // 7b. Tracking, logo, signature stem blacklist + const SIGNATURE_STEM_PATTERNS = [ + /logo/i, + /(^|[-_.\s])(sig|signature)($|[-_.\s\d])/i, + /banner/i, + /header/i, + /footer/i, + /avatar/i, + /badge/i, + /icon/i, + /button/i, + /social/i, + /facebook/i, + /twitter/i, + /instagram/i, + /linkedin/i, + /youtube/i, + /tiktok/i, + /pinterest/i, + /whatsapp/i, + /spacer/i, + /pixel/i, + /tracking/i, + /divider/i, + /border/i, + /bullet/i, + /rating/i, + /star/i, + /thumbnail/i, + /thumb/i, + /outlook-[a-z0-9]+/i, + /^image\d*$/i, + /^unnamed/i, + /^pasted/i, + /^photo$/i, + /^img$/i, + /^attachment$/i, + ] + + for (let i = 0; i < SIGNATURE_STEM_PATTERNS.length; i++) { + if (SIGNATURE_STEM_PATTERNS[i].test(stem)) { + return { + eligible: false, + reason: + 'SIGNATURE_OR_LOGO_PATTERN:' + SIGNATURE_STEM_PATTERNS[i].source, + } + } + } + + return { eligible: true, reason: 'CANONICAL_IMAGE_SCAN' } + } + + // Unknown or unsupported extension + return { eligible: false, reason: 'UNKNOWN_OR_UNSUPPORTED_EXTENSION:.' + ext } +} + +/** + * Backward-compatible boolean evaluator. + * + * @param {Object} att - Attachment blob or mock object + * @returns {boolean} True if eligible canonical document + */ +function isEligibleAttachment(att) { + return evaluateAttachmentEligibility(att).eligible +} + +/** + * Retrieves attachments from a Gmail message, excluding inline images by default. + * + * @param {Object} msg - GmailMessage or mock + * @returns {Array} Array of attachments + */ +function getMessageAttachments_(msg) { + if (!msg) return [] + if (typeof msg.getAttachments === 'function') { + try { + return ( + msg.getAttachments({ + includeInlineImages: false, + includeAttachments: true, + }) || [] + ) + } catch { + return msg.getAttachments() || [] + } + } + return msg.attachments || [] +} + +/** + * Enriches email snippet text with eligible attachment filenames so classifiers + * (deterministic rules and Gemini) have full document context. + * + * @param {string} snippet - Raw or truncated body snippet + * @param {Object} message - GmailMessage or mock + * @returns {string} Enriched snippet string + */ +function enrichSnippetWithAttachments_(snippet, message) { + var baseSnippet = (snippet || '').trim() + if (!message || typeof message.getAttachments !== 'function') { + return baseSnippet + } + if (baseSnippet.indexOf('[Attached:') !== -1) { + return baseSnippet + } + try { + var atts = message.getAttachments() || [] + var attNames = atts + .map(function (a) { + return typeof a.getName === 'function' ? a.getName() : a.name || '' + }) + .filter(function (name) { + if (!name || typeof name !== 'string') return false + var clean = name.trim() + var dot = clean.lastIndexOf('.') + var ext = dot > 0 ? clean.slice(dot + 1).toLowerCase() : '' + var stem = + dot > 0 ? clean.slice(0, dot).toLowerCase() : clean.toLowerCase() + if ( + /(logo|banner|header|footer|avatar|badge|icon|button|spacer|pixel|tracking|divider|rating|star|thumb|unnamed|outlook-)/i.test( + stem + ) || + /(^|[-_.])sig(nature)?([-_.]|$)/i.test(stem) || + /^image\d*$/i.test(stem) + ) { + return false + } + if ( + /^(ics|ical|vcf|vcard|htm|html|css|js|json|xml|dat|p7s|p7m|eml|msg)$/i.test( + ext + ) + ) { + return false + } + return true + }) + if (attNames.length > 0) { + return ( + (baseSnippet ? baseSnippet + ' ' : '') + + '[Attached: ' + + attNames.join(', ') + + ']' + ) + } + } catch { + // ignore attachment inspection errors + } + return baseSnippet +} + +function getExistingChildFolder_(parentFolder, folderName) { + const folders = + parentFolder && typeof parentFolder.getFoldersByName === 'function' + ? parentFolder.getFoldersByName(folderName) + : null + return folders && typeof folders.hasNext === 'function' && folders.hasNext() + ? folders.next() + : null +} + +function getOrCreateChildFolder_(parentFolder, folderName, lockService) { + const existing = getExistingChildFolder_(parentFolder, folderName) + if (existing) { + return existing + } + const lockSvc = + lockService || (typeof LockService !== 'undefined' ? LockService : null) + const lock = + lockSvc && typeof lockSvc.getScriptLock === 'function' + ? lockSvc.getScriptLock() + : null + let acquired = false + if (lock && typeof lock.tryLock === 'function') { + try { + acquired = lock.tryLock(5000) + } catch {} + } + try { + return ( + getExistingChildFolder_(parentFolder, folderName) || + parentFolder.createFolder(folderName) + ) + } finally { + if (acquired && lock && typeof lock.releaseLock === 'function') { + try { + lock.releaseLock() + } catch { + // Ignore lock release errors + } + } + } +} + +function resolveDriveRootContainer_( + driveApp, + rootFolderName, + createIfMissing, + lockService +) { + const drive = + driveApp !== undefined + ? driveApp + : typeof DriveApp !== 'undefined' + ? DriveApp + : null + if (!drive) { + throw new Error('DriveApp service unavailable') + } + + let root = + typeof drive.getRootFolder === 'function' ? drive.getRootFolder() : drive + + if (!root || typeof root.getFoldersByName !== 'function') { + throw new Error('DriveApp service unavailable or invalid root folder') + } + + const trimmedRootName = + typeof rootFolderName === 'string' ? rootFolderName.trim() : '' + if (trimmedRootName) { + root = createIfMissing + ? getOrCreateChildFolder_(root, trimmedRootName, lockService) + : getExistingChildFolder_(root, trimmedRootName) + } + return root +} + +/** + * Idempotently traverses or creates the 2-level Drive taxonomy path (Domain / Subfolder), + * optionally nested under a configured root folder (`DRIVE_ROOT_FOLDER_NAME`). + * + * @param {string} canonicalDomain - e.g. "02_Finance_Legal" + * @param {string} subfolderName - e.g. "Banking" + * @param {Object} driveApp - GAS DriveApp service (injected) + * @param {string|Object} [rootFolderNameOrOptions] - Optional Drive container folder name or options ({ rootFolderName, lockService }) + * @returns {Object} Target folder object + */ +function ensureDriveTaxonomyFolder( + canonicalDomain, + subfolderName, + driveApp, + rootFolderNameOrOptions +) { + const rootFolderName = + typeof rootFolderNameOrOptions === 'string' + ? rootFolderNameOrOptions + : rootFolderNameOrOptions?.rootFolderName || '' + const lockService = + typeof rootFolderNameOrOptions === 'object' && rootFolderNameOrOptions + ? rootFolderNameOrOptions.lockService || + rootFolderNameOrOptions.LockService + : null + const root = resolveDriveRootContainer_( + driveApp, + rootFolderName, + true, + lockService + ) + const domainFolder = getOrCreateChildFolder_( + root, + canonicalDomain, + lockService + ) + if (!subfolderName) return domainFolder + + return getOrCreateChildFolder_(domainFolder, subfolderName, lockService) +} + +/** + * Non-mutating lookup of an existing 2-level Drive taxonomy folder. + * Returns null instead of creating folders when any segment does not exist. + * + * @param {string} canonicalDomain - e.g. "02_Finance_Legal" + * @param {string} subfolderName - e.g. "Banking" + * @param {Object} driveApp - GAS DriveApp service (injected) + * @param {string} [rootFolderName] - Optional Drive container folder name + * @returns {Object|null} Existing target folder or null + */ +function findDriveTaxonomyFolder_( + canonicalDomain, + subfolderName, + driveApp, + rootFolderName +) { + const root = resolveDriveRootContainer_(driveApp, rootFolderName, false) + if (!root) return null + const domainFolder = getExistingChildFolder_(root, canonicalDomain) + if (!domainFolder) return null + if (!subfolderName) return domainFolder + return getExistingChildFolder_(domainFolder, subfolderName) +} + +function extractBlobBytes_(blob) { + try { + if (typeof blob?.getBytes === 'function') { + return blob.getBytes() || Buffer.from('') + } + if (blob?.bytes) { + return blob.bytes + } + if (Buffer.isBuffer(blob)) { + return blob + } + } catch { + return Buffer.from('') + } + return Buffer.from('') +} + +function extractFileSize_(file) { + if (!file) return 0 + if (typeof file.getSize === 'function') { + return file.getSize() + } + if (file.size !== undefined) { + return file.size + } + return 0 +} + +/** + * Collects candidate existing Drive files for duplicate comparison, including both + * exact filename matches (`getFilesByName(fileName)`) and conflict-renamed variants + * (`stem_<timestamp>.ext`) when `folder.getFiles()` is available. + * + * @param {Object} folder - Drive folder + * @param {string} fileName - Original attachment filename + * @returns {{ hasNext: function(): boolean, next: function(): Object }} Iterator over candidate files + */ +function collectCandidateDriveFiles_(folder, fileName) { + const items = [] + const seenRefs = new Set() + + function addFromIterator(iter, filterFn) { + if (!iter || typeof iter.hasNext !== 'function') return + while (iter.hasNext()) { + const f = iter.next() + if (!f || seenRefs.has(f)) continue + if (filterFn && !filterFn(f)) continue + seenRefs.add(f) + items.push(f) + } + } + + if (folder && typeof folder.getFilesByName === 'function') { + addFromIterator(folder.getFilesByName(fileName)) + } + + if (folder && typeof folder.getFiles === 'function' && fileName) { + const clean = String(fileName).trim() + const dotIdx = clean.lastIndexOf('.') + const stem = (dotIdx > 0 ? clean.slice(0, dotIdx) : clean).toLowerCase() + const ext = dotIdx > 0 ? clean.slice(dotIdx).toLowerCase() : '' + + addFromIterator(folder.getFiles(), function (f) { + const fName = ( + typeof f.getName === 'function' ? f.getName() : f.name || '' + ).toLowerCase() + if (!fName) return false + if (fName === clean.toLowerCase()) return true + return fName.startsWith(stem + '_') && (ext === '' || fName.endsWith(ext)) + }) + } + + let idx = 0 + return { + hasNext: function () { + return idx < items.length + }, + next: function () { + return items[idx++] + }, + } +} + +/** + * Finds and returns the first existing file in `existingFiles` that matches + * `newFileBlob` by size and MD5 content hash, or null if none match. + * + * @param {Object} existingFiles - Iterator of Drive files + * @param {Object} newFileBlob - Blob of incoming file + * @param {Object} [helperFns] - Optional helper functions ({ getFileHash }) + * @returns {Object|null} Matching Drive file or null + */ +function findMatchingDuplicateFile_(existingFiles, newFileBlob, helperFns) { + if ( + !existingFiles || + typeof existingFiles.hasNext !== 'function' || + !newFileBlob + ) { + return null + } + const hashFn = helperFns?.getFileHash || getFileHash + const newFileBytes = extractBlobBytes_(newFileBlob) + const newFileLength = newFileBytes.length + let newFileHash = null + + while (existingFiles.hasNext()) { + const existingFile = existingFiles.next() + if (extractFileSize_(existingFile) !== newFileLength) { + continue + } + + if (newFileHash === null) { + try { + newFileHash = hashFn(newFileBlob) + } catch { + return null + } + } + + const existingBlob = + typeof existingFile.getBlob === 'function' + ? existingFile.getBlob() + : existingFile + if (hashFn(existingBlob) === newFileHash) { + return existingFile + } + } + return null +} + +/** + * Checks if an exact duplicate file already exists in target folder (size match + MD5 hash). + * + * @param {Object} existingFiles - Iterator from folder.getFilesByName + * @param {Object} newFileBlob - Blob of incoming file + * @param {Object} [helperFns] - Optional helper functions ({ getFileHash }) + * @returns {boolean} + */ +function isDuplicateAttachment(existingFiles, newFileBlob, helperFns) { + return Boolean( + findMatchingDuplicateFile_(existingFiles, newFileBlob, helperFns) + ) +} + +/** + * Resolves naming conflicts for attachments. If a file of the same name exists + * with different content, appends a timestamp before the extension. + * + * @param {Object} folder - Target Drive folder + * @param {string} fileName - Attachment filename + * @param {Object} newFileBlob - Blob being saved + * @param {Object} [options] - Injected GAS services ({ Utilities, Session }) + * @returns {string} Safe filename + */ +function resolveAttachmentName(folder, fileName, newFileBlob, options) { + const existingFiles = + folder && typeof folder.getFilesByName === 'function' + ? folder.getFilesByName(fileName) + : null + if ( + !existingFiles || + typeof existingFiles.hasNext !== 'function' || + !existingFiles.hasNext() + ) { + return fileName + } + + const utils = + options?.Utilities || (typeof Utilities !== 'undefined' ? Utilities : null) + const session = + options?.Session || (typeof Session !== 'undefined' ? Session : null) + + let timeTag = + utils && + session && + typeof utils.formatDate === 'function' && + typeof session.getScriptTimeZone === 'function' + ? utils.formatDate(new Date(), session.getScriptTimeZone(), '_HHmmssSSS') + : '_' + Date.now() + + if (typeof timeTag === 'string' && !timeTag.startsWith('_')) { + timeTag = '_' + timeTag.replace(/[^a-zA-Z0-9]/g, '') + } + + const renamed = fileName.replace(/(\.[\w-]+)$/i, timeTag + '$1') + const finalName = renamed === fileName ? fileName + timeTag : renamed + + if (newFileBlob && typeof newFileBlob.setName === 'function') { + newFileBlob.setName(finalName) + } + return finalName +} + +/** + * Unzips a canonical .zip archive into a dedicated subfolder within targetFolder. + * Filters out OS artifacts, checks document eligibility, skips duplicates, and tags files. + * + * @param {Folder} targetFolder - Parent Google Drive folder + * @param {Blob|File} zipBlobOrFile - The zip file or blob to extract + * @param {Object} [services] - Utilities, DriveApp, getFileHash + * @param {Object} [metadata] - { canonicalDomain, subfolderName, tagBlock, sourceFileName } + * @returns {Array} Array of saved file summary objects + */ +function extractZipArchiveToSubfolder( + targetFolder, + zipBlobOrFile, + services, + metadata +) { + if (!targetFolder) return [] + const utils = + services?.Utilities || (typeof Utilities !== 'undefined' ? Utilities : null) + if (!utils || typeof utils.unzip !== 'function') return [] + + let zipBlob = zipBlobOrFile + let fileName = '' + if (zipBlobOrFile && typeof zipBlobOrFile.getBlob === 'function') { + zipBlob = zipBlobOrFile.getBlob() + fileName = + typeof zipBlobOrFile.getName === 'function' ? zipBlobOrFile.getName() : '' + } else if (zipBlobOrFile && typeof zipBlobOrFile.getName === 'function') { + fileName = zipBlobOrFile.getName() + } + if (!fileName && metadata?.sourceFileName) { + fileName = metadata.sourceFileName + } + if (!fileName) fileName = 'archive.zip' + + const zipStem = fileName.replace(/\.zip$/i, '') + if (!zipStem) return [] + + const extractedSummaries = [] + try { + const unzippedBlobs = utils.unzip(zipBlob) + if (!unzippedBlobs || unzippedBlobs.length === 0) return [] + + const maxEntries = 25 + if (unzippedBlobs.length > maxEntries) { + console.warn( + '[extractZipArchiveToSubfolder] Archive "' + + fileName + + '" contains ' + + unzippedBlobs.length + + ' entries; truncating extraction to first ' + + maxEntries + + ' entries.' + ) + } + + const archiveFolder = getOrCreateChildFolder_(targetFolder, zipStem) + const extractLimit = Math.min(unzippedBlobs.length, maxEntries) + const helperFns = { + getFileHash: services?.getFileHash || getFileHash, + } + + for (let z = 0; z < extractLimit; z++) { + const entryBlob = unzippedBlobs[z] + if (!entryBlob) continue + const entryName = + typeof entryBlob.getName === 'function' + ? entryBlob.getName() + : 'unnamed' + + const slashIdx = Math.max( + entryName.lastIndexOf('/'), + entryName.lastIndexOf('\\') + ) + const baseEntryName = + slashIdx !== -1 ? entryName.substring(slashIdx + 1) : entryName + + if ( + !baseEntryName || + baseEntryName.charAt(0) === '.' || + entryName.indexOf('__MACOSX') !== -1 || + baseEntryName.toLowerCase() === 'thumbs.db' + ) { + continue + } + + if ( + typeof isEligibleAttachment === 'function' && + !isEligibleAttachment(entryBlob) + ) { + console.log( + '[extractZipArchiveToSubfolder] Skipped ineligible file inside zip: ' + + baseEntryName + ) + continue + } + + const existingCandidates = collectCandidateDriveFiles_( + archiveFolder, + baseEntryName + ) + + if ( + typeof isDuplicateAttachment === 'function' && + isDuplicateAttachment(existingCandidates, entryBlob, helperFns) + ) { + console.log( + '[extractZipArchiveToSubfolder] Skipped exact duplicate inside zip: ' + + baseEntryName + ) + continue + } + + const safeEntryName = resolveAttachmentName( + archiveFolder, + baseEntryName, + entryBlob, + services + ) + if (entryBlob && typeof entryBlob.setName === 'function') { + entryBlob.setName(safeEntryName) + } + const extractedFile = archiveFolder.createFile(entryBlob) + const extractedId = + typeof extractedFile.getId === 'function' ? extractedFile.getId() : '' + const extractedUrl = + typeof extractedFile.getUrl === 'function' + ? extractedFile.getUrl() + : 'https://drive.google.com/file/d/' + extractedId + + if (typeof extractedFile.setDescription === 'function') { + try { + const desc = + (metadata?.tagBlock ? metadata.tagBlock + '\n' : '') + + 'Extracted-From: ' + + fileName + extractedFile.setDescription(desc) + } catch {} + } + + const cDomain = metadata?.canonicalDomain || '' + const sFolder = metadata?.subfolderName || '' + + extractedSummaries.push({ + name: + zipStem + + '/' + + (typeof extractedFile.getName === 'function' + ? extractedFile.getName() + : safeEntryName), + url: extractedUrl, + id: extractedId, + domain: cDomain, + subfolder: sFolder ? sFolder + '/' + zipStem : zipStem, + }) + + console.log( + '[extractZipArchiveToSubfolder] Auto-unzipped: ' + + (cDomain ? cDomain + '/' : '') + + (sFolder ? sFolder + '/' : '') + + zipStem + + '/' + + safeEntryName + ) + } + } catch (unzipErr) { + console.warn( + '[extractZipArchiveToSubfolder] Auto-unzip warning for ' + + fileName + + ': ' + + unzipErr.message + ) + } + + return extractedSummaries +} + +/** + * Unzips an existing .zip file in Google Drive into a subfolder named after the zip stem. + * + * @param {string} fileIdOrName - File ID or file name in targetFolder + * @param {Folder} targetFolder - Drive folder containing the zip file + * @param {Object} [services] - { DriveApp, Utilities } + * @returns {Array} Array of extracted file summaries + */ +function unzipExistingDriveArchive(fileIdOrName, targetFolder, services) { + const drive = + services?.DriveApp || (typeof DriveApp !== 'undefined' ? DriveApp : null) + if (!drive && !targetFolder) { + console.error( + '[unzipExistingDriveArchive] DriveApp or targetFolder required.' + ) + return [] + } + + let zipFile = null + let parentFolder = targetFolder + + if (targetFolder && typeof targetFolder.getFilesByName === 'function') { + const files = targetFolder.getFilesByName(fileIdOrName) + if (files && files.hasNext && files.hasNext()) { + zipFile = files.next() + } + } + + if (!zipFile && drive && typeof drive.getFileById === 'function') { + try { + zipFile = drive.getFileById(fileIdOrName) + if (!parentFolder && typeof zipFile.getParents === 'function') { + const parents = zipFile.getParents() + if (parents && parents.hasNext && parents.hasNext()) { + parentFolder = parents.next() + } + } + } catch {} + } + + if (!zipFile || !parentFolder) { + console.error( + '[unzipExistingDriveArchive] Could not locate zip file: ' + fileIdOrName + ) + return [] + } + + return extractZipArchiveToSubfolder(parentFolder, zipFile, services, { + sourceFileName: zipFile.getName(), + }) +} + +/** + * Persists attached documents from a canonical email thread to Google Drive along + * the label's taxonomy path, skipping duplicates and strictly ignoring non-canonical emails. + * + * @param {Object} thread - Gmail thread object + * @param {Object} classification - AI classification result + * @param {Object} config - Classifier config + * @param {Object} services - Injected GAS services ({ DriveApp, Utilities, Session }) + * @returns {Object[]} Array of saved file metadata ({ name, url, id, domain, subfolder }) + */ +function persistCanonicalAttachmentsToDrive( + thread, + classification, + config, + services +) { + // STRICT NON-CANONICAL GATE: If email has no canonical domain, ignore attachments completely! + if (!isCanonicalClassification(classification, config)) { + console.log( + '[persistCanonicalAttachmentsToDrive] Non-canonical or null domain; skipping attachment persistence.' + ) + return [] + } + + let driveApp = services?.DriveApp + if (typeof driveApp === 'undefined') { + driveApp = typeof DriveApp !== 'undefined' ? DriveApp : null + } + if (!driveApp) { + console.error( + '[persistCanonicalAttachmentsToDrive] DriveApp is unavailable; cannot persist attachments.' + ) + return [] + } + + const canonicalDomain = + classification.canonicalDomain || classification.canonical_label + const subLabel = classification.subLabel || '' + const subfolderName = resolveTaxonomySubfolderName(canonicalDomain, subLabel) + + let targetFolder + try { + targetFolder = ensureDriveTaxonomyFolder( + canonicalDomain, + subfolderName, + driveApp, + config?.driveRootFolderName + ) + } catch (err) { + console.error( + '[persistCanonicalAttachmentsToDrive] Failed to ensure taxonomy folder ' + + canonicalDomain + + '/' + + subfolderName + + ': ' + + err.message + ) + return [] + } + + const messages = + typeof thread.getMessages === 'function' ? thread.getMessages() : [] + const savedFiles = [] + const helperFns = { + getFileHash: services?.getFileHash || getFileHash, + } + const subject = + classification?.title || + (typeof thread.getFirstMessageSubject === 'function' + ? thread.getFirstMessageSubject() + : thread.subject || '') + + messages.forEach((msg) => { + const attachments = getMessageAttachments_(msg) + attachments.forEach((att) => { + if (!isEligibleAttachment(att)) { + console.log( + '[persistCanonicalAttachmentsToDrive] Skipped ineligible attachment (signature/tracking pixel or empty): ' + + (typeof att.getName === 'function' ? att.getName() : 'unnamed') + ) + return + } + + const fileName = + typeof att.getName === 'function' ? att.getName() : att.name + const newFileBlob = + typeof att.copyBlob === 'function' ? att.copyBlob() : att + const existingFiles = collectCandidateDriveFiles_(targetFolder, fileName) + + if (isDuplicateAttachment(existingFiles, newFileBlob, helperFns)) { + console.log( + '[persistCanonicalAttachmentsToDrive] Skipped exact duplicate attachment: ' + + fileName + ) + return + } + + const finalName = resolveAttachmentName( + targetFolder, + fileName, + newFileBlob, + services + ) + console.log( + '[persistCanonicalAttachmentsToDrive] Saving attachment to ' + + canonicalDomain + + '/' + + subfolderName + + ': ' + + finalName + ) + + try { + const file = targetFolder.createFile(newFileBlob) + const fileId = typeof file.getId === 'function' ? file.getId() : '' + const fileUrl = + typeof file.getUrl === 'function' + ? file.getUrl() + : 'https://drive.google.com/file/d/' + fileId + + const tagDate = + services?.Utilities && + typeof services.Utilities.formatDate === 'function' + ? services.Utilities.formatDate( + new Date(), + 'GMT', + "yyyy-MM-dd'T'HH:mm:ss'Z'" + ) + : new Date().toISOString() + const tagBlock = + '[AI_INDEXED] ' + + tagDate + + '\nDomain: ' + + canonicalDomain + + '\nSub-label: ' + + (classification.subLabel || '') + + '\nSource: Gmail Attachment' + + '\nThread-ID: ' + + (typeof thread.getId === 'function' + ? thread.getId() + : thread.id || '') + + (subject ? '\nEmail-Subject: ' + subject : '') + + if (typeof file.setDescription === 'function') { + try { + file.setDescription(tagBlock) + } catch { + // Non-blocking metadata description tagging + } + } + + savedFiles.push({ + name: typeof file.getName === 'function' ? file.getName() : finalName, + url: fileUrl, + id: fileId, + domain: canonicalDomain, + subfolder: subfolderName, + }) + + // Auto-Unzip Enhancement for .zip archives + if (fileName.toLowerCase().endsWith('.zip')) { + const extractedFiles = extractZipArchiveToSubfolder( + targetFolder, + newFileBlob, + services, + { + canonicalDomain: canonicalDomain, + subfolderName: subfolderName, + tagBlock: tagBlock, + sourceFileName: fileName, + } + ) + if (extractedFiles && extractedFiles.length > 0) { + for (let ef = 0; ef < extractedFiles.length; ef++) { + savedFiles.push(extractedFiles[ef]) + } + } + } + } catch (saveErr) { + console.error( + '[persistCanonicalAttachmentsToDrive] Error saving file ' + + finalName + + ': ' + + saveErr.message + ) + Object.defineProperty(savedFiles, 'hadErrors', { + value: true, + enumerable: false, + configurable: true, + }) + } + }) + }) + + return savedFiles +} + +/** + * Resolves the canonical domain (e.g. "01_Household") from thread labels. + * Inspects both root domain identifiers and canonical sub-label prefixes. + * + * @param {string[]} rawLabels - Array of label names on the thread + * @param {string[]} [canonicalDomains] - Optional list of canonical domain keys + * @returns {string|null} Canonical domain key or null if non-canonical + */ +function resolveCanonicalDomainFromLabels(rawLabels, canonicalDomains) { + if (!rawLabels || !Array.isArray(rawLabels)) return null + const allowed = canonicalDomains || [ + '01_Household', + '02_Finance_Legal', + '03_Vehicles', + '04_Family_Health', + '05_Tech_Infrastructure', + '06_Work_Career', + '07_Community_NonProfit', + ] + // 1. Direct match on root domain codes (e.g. "01_Household") + for (let i = 0; i < rawLabels.length; i++) { + const l = rawLabels[i] + if (allowed.includes(l) || /^0[1-7]_/.test(l)) { + return l + } + } + // 2. Canonical sub-label prefix mapping + for (let j = 0; j < rawLabels.length; j++) { + const lName = (rawLabels[j] || '').toLowerCase() + if (lName.startsWith('household/')) return '01_Household' + if (lName.startsWith('finance/')) return '02_Finance_Legal' + if (lName.startsWith('vehicles/')) return '03_Vehicles' + if (lName.startsWith('family/')) return '04_Family_Health' + if (lName.startsWith('tech/')) return '05_Tech_Infrastructure' + if (lName.startsWith('work/')) return '06_Work_Career' + if ( + lName.startsWith('community/') || + lName.startsWith('projects/') || + lName.startsWith('nonprofit/') + ) + return '07_Community_NonProfit' + } + return null +} + +/** + * Scans historical Gmail threads for attachments across the 7 Canonical Domains, + * verifies whether each attachment exists in Google Drive under the correct taxonomy path, + * checks/applies native [AI_INDEXED] description tags, and optionally backfills missing files. + * + * @param {Object} [options] - Execution options + * @param {boolean} [options.dryRun=true] - If true, only reports discrepancies; if false, uploads missing attachments and tags them. + * @param {string} [options.query] - Custom search query (defaults to searching canonical domain labels with attachments). + * @param {number} [options.maxThreads=50] - Maximum threads to inspect in this batch. + * @param {Object} [config] - Classifier configuration (defaults to getAiClassifierConfig()). + * @param {Object} [services] - Service injection for testing ({ GmailApp, DriveApp, Utilities }). + * @returns {Object} report + */ +function auditAndBackfillCanonicalAttachments(options, config, services) { + const opts = options || {} + const isDryRun = opts.dryRun !== false + const maxThreads = opts.maxThreads || 50 + + const cfg = resolveEffectiveConfig_(config) + const canonicalDomains = cfg.canonicalDomains || [ + '01_Household', + '02_Finance_Legal', + '03_Vehicles', + '04_Family_Health', + '05_Tech_Infrastructure', + '06_Work_Career', + '07_Community_NonProfit', + ] + + const gmail = + services?.GmailApp || (typeof GmailApp !== 'undefined' ? GmailApp : null) + const drive = + services?.DriveApp || (typeof DriveApp !== 'undefined' ? DriveApp : null) + const utils = + services?.Utilities || (typeof Utilities !== 'undefined' ? Utilities : null) + + if (!gmail || !drive) { + throw new Error( + 'GmailApp and DriveApp services are required for attachment audit/backfill.' + ) + } + + const defaultCanonicalQuery = + 'has:attachment (label:01_Household OR label:02_Finance_Legal OR label:03_Vehicles OR label:04_Family_Health OR label:05_Tech_Infrastructure OR label:06_Work_Career OR label:07_Community_NonProfit OR label:Household OR label:Finance OR label:Vehicles OR label:Family OR label:Tech OR label:Work OR label:Community OR label:Projects OR label:NonProfit)' + const searchQuery = opts.query || defaultCanonicalQuery + + let threads = [] + if (typeof gmail.search === 'function') { + threads = sortThreadsByLastMessageDate_( + gmail.search(searchQuery, 0, maxThreads) || [] + ) + } + + const report = { + scannedThreads: threads.length, + threadsWithEligibleAttachments: 0, + totalAttachmentsInspected: 0, + totalEligibleAttachments: 0, + filteredGarbageCount: 0, + eligibleByType: {}, + filteredByReason: {}, + alreadyStoredAndTagged: 0, + alreadyStoredUntagged: 0, + missingFromDrive: 0, + backfilledCount: 0, + taggedCount: 0, + dryRun: isDryRun, + items: [], + } + + const startTime = Date.now() + const timeBudgetMs = opts.timeBudgetMs || 270000 + const folderCache = {} + + for (let i = 0; i < threads.length; i++) { + if (Date.now() - startTime > timeBudgetMs) { + console.warn( + '[auditAndBackfillCanonicalAttachments] Execution reached time budget safety ceiling; concluding batch cleanly.' + ) + report.timeBudgetReached = true + break + } + + const thread = threads[i] + if (!thread) continue + const threadId = + typeof thread.getId === 'function' ? thread.getId() : thread.id || '' + const subject = + typeof thread.getFirstMessageSubject === 'function' + ? thread.getFirstMessageSubject() + : thread.subject || '' + + let rawLabels = [] + if (typeof thread.getLabels === 'function') { + const labelObjs = thread.getLabels() || [] + rawLabels = labelObjs.map((l) => + typeof l.getName === 'function' ? l.getName() : String(l) + ) + } else if (Array.isArray(thread.labels)) { + rawLabels = thread.labels + } + + const canonicalDomain = resolveCanonicalDomainFromLabels( + rawLabels, + canonicalDomains + ) + if (!canonicalDomain) continue + + const isTaxonomySubLabel = (l) => + Boolean( + l && + l.includes('/') && + !l.toLowerCase().startsWith('ai/') && + !l.startsWith('Retention/') && + !l.includes('Archives') + ) + const subLabel = + rawLabels.find( + (l) => isTaxonomySubLabel(l) && !l.includes(canonicalDomain) + ) || + rawLabels.find((l) => isTaxonomySubLabel(l)) || + '' + const subfolderName = resolveTaxonomySubfolderName( + canonicalDomain, + subLabel + ) + + const msgs = + typeof thread.getMessages === 'function' + ? thread.getMessages() + : thread.messages || [] + const eligibleAtts = [] + + msgs.forEach((msg) => { + const atts = getMessageAttachments_(msg) + atts.forEach((att) => { + report.totalAttachmentsInspected++ + const evalResult = evaluateAttachmentEligibility(att) + if (!evalResult.eligible) { + report.filteredGarbageCount++ + const reasonKey = evalResult.reason.split(':')[0] + report.filteredByReason[reasonKey] = + (report.filteredByReason[reasonKey] || 0) + 1 + return + } + eligibleAtts.push(att) + }) + }) + + if (eligibleAtts.length === 0) { + continue + } + + const folderKey = `${canonicalDomain}::${subfolderName}` + let targetFolder = folderCache[folderKey] + if (targetFolder === undefined) { + try { + targetFolder = isDryRun + ? findDriveTaxonomyFolder_( + canonicalDomain, + subfolderName, + drive, + cfg.driveRootFolderName + ) + : ensureDriveTaxonomyFolder( + canonicalDomain, + subfolderName, + drive, + cfg.driveRootFolderName + ) + folderCache[folderKey] = targetFolder + } catch (err) { + console.error( + '[auditAndBackfillCanonicalAttachments] Error accessing folder ' + + canonicalDomain + + '/' + + subfolderName + + ': ' + + err.message + ) + continue + } + } + + report.threadsWithEligibleAttachments++ + + eligibleAtts.forEach((att) => { + report.totalEligibleAttachments++ + const fileName = + typeof att.getName === 'function' + ? att.getName() + : att.name || 'unnamed' + const cleanName = fileName.trim() + const dotIndex = cleanName.lastIndexOf('.') + const ext = + dotIndex > 0 ? cleanName.slice(dotIndex + 1).toLowerCase() : 'other' + report.eligibleByType[ext] = (report.eligibleByType[ext] || 0) + 1 + + let fileSize = extractFileSize_(att) + if (!fileSize && typeof att.getBytes === 'function') { + const b = att.getBytes() + fileSize = b ? b.length : 0 + } + const fileSizeKb = Math.round((fileSize / 1024) * 10) / 10 + const mimeType = + typeof att.getContentType === 'function' + ? att.getContentType() + : att.contentType || '' + + const newFileBlob = + typeof att.copyBlob === 'function' ? att.copyBlob() : att + + const existingFiles = targetFolder + ? collectCandidateDriveFiles_(targetFolder, fileName) + : null + const helperFns = { + getFileHash: getFileHash, + Utilities: utils, + } + + const foundFile = findMatchingDuplicateFile_( + existingFiles, + newFileBlob, + helperFns + ) + + const tagDate = + utils && typeof utils.formatDate === 'function' + ? utils.formatDate(new Date(), 'GMT', "yyyy-MM-dd'T'HH:mm:ss'Z'") + : new Date().toISOString() + const tagBlock = + '[AI_INDEXED] ' + + tagDate + + '\nDomain: ' + + canonicalDomain + + '\nSub-label: ' + + subLabel + + '\nSource: Gmail Attachment Backfill' + + '\nThread-ID: ' + + threadId + + '\nEmail-Subject: ' + + subject + + if (foundFile) { + const desc = + typeof foundFile.getDescription === 'function' + ? foundFile.getDescription() + : '' + + if (desc && desc.includes('[AI_INDEXED]')) { + report.alreadyStoredAndTagged++ + report.items.push({ + threadId: threadId, + subject: subject, + fileName: fileName, + fileSizeKb: fileSizeKb, + mimeType: mimeType, + extension: ext, + domain: canonicalDomain, + subfolder: subfolderName, + status: 'ALREADY_STORED_AND_TAGGED', + }) + } else { + report.alreadyStoredUntagged++ + if (!isDryRun && typeof foundFile.setDescription === 'function') { + try { + foundFile.setDescription(tagBlock) + report.taggedCount++ + } catch { + // Non-blocking + } + } + report.items.push({ + threadId: threadId, + subject: subject, + fileName: fileName, + fileSizeKb: fileSizeKb, + mimeType: mimeType, + extension: ext, + domain: canonicalDomain, + subfolder: subfolderName, + status: isDryRun ? 'STORED_BUT_UNTAGGED' : 'TAGGED_EXISTING', + }) + } + } else { + report.missingFromDrive++ + if (!isDryRun && targetFolder) { + try { + const finalName = resolveAttachmentName( + targetFolder, + fileName, + newFileBlob, + services + ) + const createdFile = targetFolder.createFile(newFileBlob) + if ( + createdFile && + typeof createdFile.setDescription === 'function' + ) { + createdFile.setDescription(tagBlock) + } + report.backfilledCount++ + report.items.push({ + threadId: threadId, + subject: subject, + fileName: finalName, + fileSizeKb: fileSizeKb, + mimeType: mimeType, + extension: ext, + domain: canonicalDomain, + subfolder: subfolderName, + status: 'BACKFILLED_TO_DRIVE', + }) + } catch (saveErr) { + report.items.push({ + threadId: threadId, + subject: subject, + fileName: fileName, + fileSizeKb: fileSizeKb, + mimeType: mimeType, + extension: ext, + domain: canonicalDomain, + subfolder: subfolderName, + status: 'BACKFILL_ERROR', + error: saveErr.message, + }) + } + } else { + report.items.push({ + threadId: threadId, + subject: subject, + fileName: fileName, + fileSizeKb: fileSizeKb, + mimeType: mimeType, + extension: ext, + domain: canonicalDomain, + subfolder: subfolderName, + status: 'MISSING_FROM_DRIVE', + }) + } + } + }) + } + + return report +} + +/** + * Convenience runner for dry-run historical attachment audit. + */ +function runDryRunAttachmentAudit(options, config, services) { + const opts = Object.assign({ dryRun: true, maxThreads: 50 }, options || {}) + const report = auditAndBackfillCanonicalAttachments(opts, config, services) + + console.log('===============================================================') + console.log(' CANONICAL ATTACHMENT AUDIT MANIFEST (DRY RUN)') + console.log('===============================================================') + console.log( + 'Scanned Threads: ' + + report.scannedThreads + + ' | Total Attachments Inspected: ' + + report.totalAttachmentsInspected + + ' | Eligible: ' + + report.totalEligibleAttachments + + ' | Filtered Garbage: ' + + report.filteredGarbageCount + ) + console.log('---------------------------------------------------------------') + console.log('ELIGIBLE ATTACHMENTS BY TYPE:') + Object.keys(report.eligibleByType || {}).forEach(function (type) { + console.log(' .' + type + ': ' + report.eligibleByType[type]) + }) + console.log('FILTERED GARBAGE BY REASON:') + Object.keys(report.filteredByReason || {}).forEach(function (reason) { + console.log(' ' + reason + ': ' + report.filteredByReason[reason]) + }) + console.log('---------------------------------------------------------------') + console.log('CURATED FILES SCHEDULED FOR DRIVE STORAGE:') + if (!report.items || report.items.length === 0) { + console.log(' (None - zero files qualify for Drive storage)') + } else { + report.items.forEach(function (item, idx) { + console.log( + ' [' + + (idx + 1) + + '] [' + + item.domain + + '/' + + item.subfolder + + '] ' + + item.fileName + + ' (' + + item.fileSizeKb + + ' KB) - Subject: "' + + item.subject + + '"' + ) + }) + } + console.log('===============================================================') + + return report +} + +/** + * Convenience runner for live historical attachment backfill. + */ +function runLiveAttachmentBackfill(options, config, services) { + const opts = Object.assign({ dryRun: false, maxThreads: 50 }, options || {}) + return auditAndBackfillCanonicalAttachments(opts, config, services) +} + +/** + * Detects whether a first argument passed to a trigger handler is a Google Apps Script + * time-driven event object rather than a classifier configuration object. + * + * @param {*} obj - Candidate argument + * @returns {boolean} True if obj is a GAS trigger event payload + */ +function isTriggerEventObject_(obj) { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { + return false + } + const hasClassifierConfigKey = + 'geminiApiKey' in obj || + 'canonicalDomains' in obj || + 'unprocessedQuery' in obj || + 'processedLabel' in obj || + 'auditLookbackDays' in obj || + 'auditDigestEmail' in obj || + 'userAccountEmail' in obj || + 'driveApp' in obj + if (hasClassifierConfigKey) { + return false + } + return ( + 'triggerUid' in obj || + 'authMode' in obj || + ('year' in obj && 'month' in obj && 'hour' in obj) + ) +} + +/** + * Resolves the effective classifier configuration, ignoring GAS time-driven + * trigger event objects when functions are invoked directly by ScriptApp triggers. + * + * @param {Object} [customConfig] - Optional custom config or trigger event object + * @returns {Object} Effective configuration + */ +function resolveEffectiveConfig_(customConfig) { + if ( + customConfig && + typeof customConfig === 'object' && + !isTriggerEventObject_(customConfig) + ) { + return customConfig + } + return typeof getAiClassifierConfig === 'function' + ? getAiClassifierConfig() + : {} +} + +/** + * Extracts a numeric timestamp (ms) from a Gmail thread for deterministic sorting. + * + * @param {Object} thread - GmailThread or mock object + * @returns {number} Epoch milliseconds or 0 if unavailable + */ +function getThreadLastMessageTimestamp_(thread) { + if (!thread || typeof thread !== 'object') return 0 + try { + const d = + typeof thread.getLastMessageDate === 'function' + ? thread.getLastMessageDate() + : thread.lastMessageDate + if (d instanceof Date && !Number.isNaN(d.getTime())) return d.getTime() + if (typeof d === 'number' && Number.isFinite(d)) return d + if (typeof d === 'string') { + const parsed = Date.parse(d) + if (!Number.isNaN(parsed)) return parsed + } + } catch { + // Non-blocking fallback + } + return 0 +} + +/** + * Sorts an array of Gmail threads by last-message date (newest first), + * preserving original order when timestamps are equal or absent. + * + * @param {Array} threads - Array of GmailThread objects + * @returns {Array} Sorted shallow copy of threads + */ +function sortThreadsByLastMessageDate_(threads) { + if (!Array.isArray(threads) || threads.length <= 1) { + return Array.isArray(threads) ? threads.slice() : [] + } + return threads + .map(function (thread, index) { + return { + thread: thread, + index: index, + ts: getThreadLastMessageTimestamp_(thread), + } + }) + .sort(function (a, b) { + if (b.ts !== a.ts) { + return b.ts - a.ts + } + return a.index - b.index + }) + .map(function (entry) { + return entry.thread + }) +} + +/** + * Verifies that a candidate Drive file in `oldFolder` actually belongs to the + * thread attachment being relocated (`Thread-ID` description match or size/hash match), + * preventing unrelated files that share a generic filename (e.g. `invoice.pdf`) from being moved. + * + * @param {Object} fileCandidate - Drive file object + * @param {Object} att - Gmail attachment object + * @param {string} threadId - Current Gmail thread ID + * @param {Object} [helperFns] - Optional helper functions ({ getFileHash }) + * @returns {boolean} True if fileCandidate matches the thread attachment + */ +function doesDriveFileMatchThreadAttachment_( + fileCandidate, + att, + threadId, + helperFns +) { + if (!fileCandidate || !att) return false + const desc = + typeof fileCandidate.getDescription === 'function' + ? fileCandidate.getDescription() || '' + : fileCandidate.description || '' + if (desc && desc.indexOf('Thread-ID:') !== -1) { + return Boolean(threadId) && desc.indexOf('Thread-ID: ' + threadId) !== -1 + } + + const candidateSize = extractFileSize_(fileCandidate) + const attSize = extractFileSize_(att) || extractBlobBytes_(att).length + if (candidateSize > 0 && attSize > 0 && candidateSize !== attSize) { + return false + } + + const hashFn = (helperFns && helperFns.getFileHash) || getFileHash + const candidateBlob = + typeof fileCandidate.getBlob === 'function' + ? fileCandidate.getBlob() + : fileCandidate + const attBlob = typeof att.copyBlob === 'function' ? att.copyBlob() : att + const candidateHash = hashFn(candidateBlob) + const attHash = hashFn(attBlob) + if (candidateHash && attHash) { + return candidateHash === attHash + } + + return true +} + +/** + * Reclassifies historical threads matching a custom Gmail search query. + * Useful for retroactive alignment of misclassified or misattributed threads. + * + * @param {string} searchQuery - Gmail search query + * @param {Object} [options] - Options: { maxThreads: 20, dryRun: false } + * @param {Object} [config] - Classifier config + * @param {Object} [services] - Dependency injection for testing { GmailApp, Utilities, classifyFn } + * @returns {Object} Report of reclassified threads + */ +function reclassifyThreadsByQuery(searchQuery, options, config, services) { + const opts = Object.assign( + { maxThreads: 20, dryRun: false, timeBudgetMs: 240000 }, + options || {} + ) + const cfg = resolveEffectiveConfig_(config) + const gmail = + (services && services.GmailApp) || + (typeof GmailApp !== 'undefined' ? GmailApp : null) + const classifyFn = + (services && services.classifyFn) || + function (s, sub, sn, c) { + return classifyWithGemini(s, sub, sn, c, services) + } + + if (!gmail) { + throw new Error('GmailApp service is required for reclassifying threads.') + } + + console.log( + '[reclassifyThreadsByQuery] Query: ' + + searchQuery + + ' (dryRun: ' + + opts.dryRun + + ', maxThreads: ' + + opts.maxThreads + + ')' + ) + + const rawThreads = gmail.search(searchQuery, 0, opts.maxThreads) || [] + const threads = sortThreadsByLastMessageDate_(rawThreads) + console.log( + '[reclassifyThreadsByQuery] Found ' + + threads.length + + ' thread(s) matching query.' + ) + + const results = [] + const startTime = Date.now() + const timeBudgetMs = opts.timeBudgetMs || 240000 + + for (let i = 0; i < threads.length; i++) { + if (Date.now() - startTime > timeBudgetMs) { + console.warn( + '[reclassifyThreadsByQuery] Execution safety ceiling reached (' + + Math.round((Date.now() - startTime) / 1000) + + 's); completing batch cleanly.' + ) + break + } + + const thread = threads[i] + const threadId = + typeof thread.getId === 'function' + ? thread.getId() + : thread.id || String(i) + const msgs = + typeof thread.getMessages === 'function' ? thread.getMessages() : [] + if (!msgs || msgs.length === 0) continue + const firstMessage = msgs[0] + const sender = + typeof firstMessage.getFrom === 'function' ? firstMessage.getFrom() : '' + const subject = + typeof firstMessage.getSubject === 'function' + ? firstMessage.getSubject() + : '' + let snippet = + typeof firstMessage.getPlainBody === 'function' + ? firstMessage.getPlainBody().substring(0, 500) + : '' + snippet = enrichSnippetWithAttachments_(snippet, firstMessage) + + let rawLabels = [] + if (typeof thread.getLabels === 'function') { + const labelObjs = thread.getLabels() || [] + rawLabels = labelObjs.map(function (l) { + return typeof l.getName === 'function' ? l.getName() : String(l) + }) + } + + let classification = null + try { + classification = + deterministicRuleClassify_(sender, subject, snippet, thread, cfg) || + classifyFn(sender, subject, snippet, cfg) + } catch (e) { + console.warn( + '[reclassifyThreadsByQuery] Classifier exception: ' + e.message + ) + } + if (!classification) continue + let oldDomain = resolveCanonicalDomainFromLabels( + rawLabels, + cfg.canonicalDomains + ) + if (!oldDomain) { + const oldCanonical = rawLabels.filter(function (l) { + return ( + (cfg.canonicalDomains || []).indexOf(l) !== -1 || /^0[1-7]_/.test(l) + ) + }) + oldDomain = oldCanonical.length > 0 ? oldCanonical[0] : null + } + const oldSubLabel = + rawLabels.find(function (l) { + return ( + l.indexOf('/') !== -1 && + l !== oldDomain && + l.toLowerCase().indexOf('ai/') !== 0 && + l.indexOf('Archives') === -1 && + l.indexOf('Retention/') === -1 + ) + }) || '' + + const newDomain = + classification.canonicalDomain || classification.canonical_label + const newSubLabel = classification.subLabel || '' + const tldChanged = oldDomain && newDomain && oldDomain !== newDomain + + const attachmentsMoved = [] + let attachmentsSaved = [] + + if (!opts.dryRun) { + cleanConflictingLabels( + thread, + classification.canonicalDomain, + classification.subLabel, + cfg + ) + let primaryTag = classification.subLabel + if (!primaryTag || isRootDomainLabel(primaryTag)) { + const domainKey = + classification.canonicalDomain || classification.canonical_label + primaryTag = + (domainKey && DEFAULT_DOMAIN_SUBLABEL_MAP[domainKey]) || + domainKey || + null + } + if (primaryTag && typeof thread.addLabel === 'function') { + const targetLabel = ensureGmailLabel(primaryTag, gmail) + thread.addLabel(targetLabel) + } + if ( + classification.category && + classification.category !== 'Primary' && + classification.action !== 'trash' + ) { + classification.action = 'archive' + } + if (classification.category && classification.action !== 'trash') { + setGmailCategoryTab(thread, classification.category) + } + + const userSent = isThreadSentOrRepliedByUser(thread, cfg.userAccountEmail) + const requestedDestructive = + classification.action === 'archive' || classification.action === 'trash' + const destructiveOk = isDestructiveActionAllowed_(classification) + + if (requestedDestructive && !userSent && destructiveOk) { + if ( + classification.action === 'trash' && + typeof thread.moveToTrash === 'function' + ) { + thread.moveToTrash() + } else if ( + classification.action === 'archive' && + typeof thread.moveToArchive === 'function' + ) { + thread.moveToArchive() + } + } + + if (cfg.processedLabel && typeof thread.addLabel === 'function') { + const processedLabel = ensureGmailLabel(cfg.processedLabel, gmail) + thread.addLabel(processedLabel) + } + + // Drive attachment relocation & backfill + const drive = + (services && services.DriveApp) || + (typeof DriveApp !== 'undefined' ? DriveApp : null) + if (drive && isCanonicalClassification(classification, cfg)) { + const oldSubfolder = oldDomain + ? resolveTaxonomySubfolderName(oldDomain, oldSubLabel) + : null + const newSubfolder = resolveTaxonomySubfolderName( + newDomain, + newSubLabel + ) + + // Relocate existing attachments if folder path changed + if ( + oldDomain && + oldSubfolder && + (oldDomain !== newDomain || oldSubfolder !== newSubfolder) + ) { + try { + const oldFolder = + findDriveTaxonomyFolder_( + oldDomain, + oldSubfolder, + drive, + cfg.driveRootFolderName + ) || + ensureDriveTaxonomyFolder( + oldDomain, + oldSubfolder, + drive, + cfg.driveRootFolderName + ) + const newFolder = ensureDriveTaxonomyFolder( + newDomain, + newSubfolder, + drive, + cfg.driveRootFolderName + ) + const helperFns = { + getFileHash: services?.getFileHash || getFileHash, + } + + msgs.forEach(function (msg) { + const atts = getMessageAttachments_(msg) + atts.forEach(function (att) { + if (!isEligibleAttachment(att)) return + const attName = + typeof att.getName === 'function' ? att.getName() : '' + if (!attName || !oldFolder) return + + const existingFiles = collectCandidateDriveFiles_( + oldFolder, + attName + ) + while ( + existingFiles && + typeof existingFiles.hasNext === 'function' && + existingFiles.hasNext() + ) { + const fileToMove = existingFiles.next() + if ( + typeof fileToMove.moveTo === 'function' && + doesDriveFileMatchThreadAttachment_( + fileToMove, + att, + threadId, + helperFns + ) + ) { + fileToMove.moveTo(newFolder) + attachmentsMoved.push({ + name: attName, + from: oldDomain + '/' + oldSubfolder, + to: newDomain + '/' + newSubfolder, + }) + console.log( + '[reclassifyThreadsByQuery] Relocated attachment "' + + attName + + '" from ' + + oldDomain + + '/' + + oldSubfolder + + ' to ' + + newDomain + + '/' + + newSubfolder + ) + break + } + } + }) + }) + } catch (e) { + console.warn( + '[reclassifyThreadsByQuery] Drive relocation warning: ' + + e.message + ) + } + } + + // Persist any eligible attachments not yet preserved + const saved = persistCanonicalAttachmentsToDrive( + thread, + classification, + cfg, + { + DriveApp: drive, + Utilities: + services && services.Utilities + ? services.Utilities + : typeof Utilities !== 'undefined' + ? Utilities + : null, + } + ) + if (saved && saved.length > 0) { + attachmentsSaved = saved + } + } + } + + const labelChanged = oldDomain !== newDomain || oldSubLabel !== newSubLabel + const summary = { + threadId: threadId, + subject: subject, + sender: sender, + oldDomain: oldDomain, + oldSubLabel: oldSubLabel, + newDomain: newDomain, + newSubLabel: newSubLabel, + labelChanged: labelChanged, + tldChanged: !!tldChanged, + action: classification.action, + category: classification.category, + attachmentsMoved: attachmentsMoved, + attachmentsSaved: attachmentsSaved, + } + + results.push(summary) + } + + if (opts.dryRun) { + console.log( + '===============================================================' + ) + console.log( + ' HISTORICAL REALIGNMENT AUDIT (DRY RUN) ' + ) + console.log( + '===============================================================' + ) + console.log('Query: ' + searchQuery) + console.log('Scanned: ' + threads.length + ' thread(s)') + console.log('Audited: ' + results.length) + results.forEach(function (item, idx) { + console.log( + ' [' + + (idx + 1) + + '] Thread: ' + + item.threadId + + ' | Subject: "' + + item.subject + + '"' + ) + console.log( + ' Old: ' + + (item.oldSubLabel || item.oldDomain || '(none)') + + ' -> New: ' + + (item.newSubLabel || item.newDomain) + + (item.labelChanged ? ' [CHANGES]' : ' [MATCHES]') + ) + console.log( + ' TLD Changed: ' + + item.tldChanged + + ' | Action: ' + + item.action + + ' | Category: ' + + item.category + ) + if (item.attachmentsMoved && item.attachmentsMoved.length > 0) { + console.log( + ' Attachments to move: ' + JSON.stringify(item.attachmentsMoved) + ) + } + }) + console.log( + '===============================================================' + ) + } + + return { + query: searchQuery, + scanned: threads.length, + reclassified: results.length, + dryRun: opts.dryRun, + items: results, + } +} + +/** + * Automatically identifies career/employment emails from the past 14 days that were + * missed or prematurely labeled 'Processed' without receiving a canonical career tag. + * Reverts 'Processed' and applies 'AI/Needs-Retry' to durably queue them for classification. + */ +function revertAndQueueMissedCareerEmails(gmailApp, config) { + const gmail = gmailApp || (typeof GmailApp !== 'undefined' ? GmailApp : null) + if (!gmail || typeof gmail.search !== 'function') return [] + + const cfg = resolveEffectiveConfig_(config) + const retryLabelName = cfg.retryLabel || 'AI/Needs-Retry' + const processedLabelName = cfg.processedLabel || 'Processed' + + const retryLabel = ensureGmailLabel(retryLabelName, gmail) + const processedLabel = ensureGmailLabel(processedLabelName, gmail) + + const recoverySearch = + 'newer_than:14d (subject:"Employment Agreement" OR subject:"Employment Contract" OR subject:"Offer Letter" OR subject:"PwC" OR subject:"KPMG")' + + const modifiedThreadIds = [] + try { + const rawThreads = gmail.search(recoverySearch, 0, 20) || [] + const threads = sortThreadsByLastMessageDate_(rawThreads) + for (let i = 0; i < threads.length; i++) { + const thread = threads[i] + if (isThreadSentOrRepliedByUser(thread, cfg.userAccountEmail)) { + continue + } + let labels = [] + if (typeof thread.getLabels === 'function') { + const lObjs = thread.getLabels() || [] + labels = lObjs.map(function (l) { + return typeof l.getName === 'function' ? l.getName() : String(l) + }) + } + const hasCareerTag = + labels.indexOf('06_Work_Career') !== -1 || + labels.indexOf('Work/Career') !== -1 || + labels.indexOf('Career/DJ') !== -1 || + labels.indexOf('Career/Rachel') !== -1 || + labels.indexOf('Career_Interviews') !== -1 + + if (!hasCareerTag) { + let changed = false + if (processedLabel && labels.indexOf(processedLabelName) !== -1) { + try { + thread.removeLabel(processedLabel) + changed = true + } catch {} + } + if (retryLabel && labels.indexOf(retryLabelName) === -1) { + try { + thread.addLabel(retryLabel) + changed = true + } catch {} + } + if (changed) { + const tId = + typeof thread.getId === 'function' ? thread.getId() : String(i) + modifiedThreadIds.push(tId) + console.log( + '[revertAndQueueMissedCareerEmails] Reverted Processed and queued thread: ' + + tId + ) + } + } + } + } catch (err) { + console.warn( + '[revertAndQueueMissedCareerEmails] Search or labeling error:', + err.message + ) + } + return modifiedThreadIds +} + +/** + * Convenience runner for re-evaluating recent emails from the past 14 days + * or emails tagged with AI/Needs-Retry that may have been missed. + */ +function runMissedEmailsSweep(config, services) { + const gmail = + (services && services.GmailApp) || + (typeof GmailApp !== 'undefined' ? GmailApp : null) + const cfg = resolveEffectiveConfig_(config) + if (typeof revertAndQueueMissedCareerEmails === 'function') { + revertAndQueueMissedCareerEmails(gmail, cfg) + } + + // 1. Direct Priority Recovery: First sweep and reclassify career & employment contract emails + const careerQuery = + 'newer_than:14d (subject:"Employment Agreement" OR subject:"Employment Contract" OR subject:"Offer Letter" OR subject:"PwC" OR subject:"KPMG")' + console.log('[runMissedEmailsSweep] Priority career sweep: ' + careerQuery) + reclassifyThreadsByQuery( + careerQuery, + { maxThreads: 20, dryRun: false }, + cfg, + services + ) + + // 2. Secondary General Recovery: Sweep other unclassified threads + const query = + 'label:AI/Needs-Retry OR (newer_than:14d -label:Processed -label:AI/Manual-Review)' + console.log('[runMissedEmailsSweep] Sweeping query: ' + query) + return reclassifyThreadsByQuery( + query, + { maxThreads: 30, dryRun: false }, + cfg, + services + ) +} + +/** + * Convenience runner to audit historical threads matching a query in dry-run mode. + */ +function runRealignmentAudit(query, options, config, services) { + const gmail = + (services && services.GmailApp) || + (typeof GmailApp !== 'undefined' ? GmailApp : null) + let searchQuery = query + + if (!searchQuery && gmail && typeof gmail.getUserLabels === 'function') { + try { + const allLabels = gmail.getUserLabels() || [] + const matchedLabels = allLabels + .filter(function (l) { + const name = typeof l.getName === 'function' ? l.getName() : String(l) + return /sister/i.test(name) + }) + .map(function (l) { + const name = typeof l.getName === 'function' ? l.getName() : String(l) + return 'label:"' + name + '"' + }) + + console.log( + '[runRealignmentAudit] Discovered sister labels in Gmail: ' + + JSON.stringify(matchedLabels) + ) + + const queryParts = matchedLabels.slice() + queryParts.push('"name change"') + searchQuery = queryParts.join(' OR ') + } catch (e) { + console.warn( + '[runRealignmentAudit] Error discovering user labels: ' + e.message + ) + } + } + + if (!searchQuery) { + searchQuery = 'label:"Family/Sisters" OR "name change"' + } + + return reclassifyThreadsByQuery( + searchQuery, + Object.assign({ maxThreads: 25, dryRun: true }, options || {}), + config, + services + ) +} + +/** + * Sweeps and reclassifies current unread items in the inbox against the + * Category-Driven Retention Invariant, archiving all non-Primary streams. + * + * @param {Object} [options] - Reclassification options (maxThreads, dryRun, timeBudgetMs) + * @param {Object} [customConfig] - Optional override config + * @param {Object} [customServices] - Optional injected services + * @returns {Object} Realignment summary report + */ +function sweepAllUnreadInboxEmails(options, customConfig, customServices) { + const opts = Object.assign({ maxThreads: 50, dryRun: false }, options || {}) + return reclassifyThreadsByQuery( + 'in:inbox is:unread -is:starred -from:me', + opts, + customConfig, + customServices + ) +} + +/** + * Checks if a thread contains any messages sent or replied by the account owner. + */ +function isThreadSentOrRepliedByUser(thread, userEmail) { + try { + const primaryEmail = (userEmail || '').toLowerCase() + if (!primaryEmail || !thread || typeof thread.getMessages !== 'function') { + return false + } + const messages = thread.getMessages() || [] + for (let m = 0; m < messages.length; m++) { + const msg = messages[m] + if (!msg || typeof msg.getFrom !== 'function') continue + const rawFrom = msg.getFrom() + const fromAddr = ( + rawFrom && typeof rawFrom === 'string' ? rawFrom : '' + ).toLowerCase() + if (fromAddr.indexOf(primaryEmail) !== -1) { + return true + } + } + } catch (e) { + console.warn( + '[isThreadSentOrRepliedByUser] Error checking message senders:', + e.message + ) + } + return false +} + +/** + * Automatically purges emails older than their category retention policy (2 years for Promotions/Social/Forums). + * Core household domains (01_Household - 07_Community_NonProfit), Primary, Updates, Starred, and User Replied/Sent threads are 100% EXEMPT. + */ +function purgeExpiredEmailsByRetentionPolicy(customConfig, customServices) { + console.log( + '[purgeExpiredEmailsByRetentionPolicy] Starting weekly category retention cleanup...' + ) + const services = customServices || {} + const config = resolveEffectiveConfig_(customConfig) + + const gmail = + services.GmailApp || (typeof GmailApp !== 'undefined' ? GmailApp : null) + if (!gmail) { + console.error( + '[purgeExpiredEmailsByRetentionPolicy] GmailApp service is missing.' + ) + return { trashedCount: 0, exemptedCount: 0 } + } + + // Explicitly exclude threads sent by user or containing user replies + const retentionQuery = + '(category:promotions OR category:social OR category:forums) older_than:2y -is:starred -from:me' + + const coreDomainLabels = config.canonicalDomains || [ + '01_Household', + '02_Finance_Legal', + '03_Vehicles', + '04_Family_Health', + '05_Tech_Infrastructure', + '06_Work_Career', + '07_Community_NonProfit', + ] + const pageSize = 50 + const maxPages = 5 + let trashedCount = 0 + let exemptedCount = 0 + let totalFound = 0 + let offset = 0 + + for (let page = 0; page < maxPages; page++) { + const rawPage = gmail.search(retentionQuery, offset, pageSize) || [] + const threads = sortThreadsByLastMessageDate_(rawPage) + if (page === 0) { + console.log( + '[purgeExpiredEmailsByRetentionPolicy] Found ' + + threads.length + + ' expired thread(s) matching 2-year retention query.' + ) + if (threads.length === 0) { + return { trashedCount: 0, exemptedCount: 0 } + } + } else if (threads.length === 0) { + break + } + + totalFound += threads.length + let pageRemainingCount = 0 + + for (let i = 0; i < threads.length; i++) { + try { + const thread = threads[i] + if (!thread) continue + const labels = + typeof thread.getLabels === 'function' ? thread.getLabels() || [] : [] + let isExempt = false + + // Safety Shield 1: Check if thread contains any Core Household Domain label + // OR any classifier sub-label. + for (let l = 0; l < labels.length; l++) { + const labelName = + typeof labels[l].getName === 'function' + ? labels[l].getName() + : String(labels[l]) + for (let cd = 0; cd < coreDomainLabels.length; cd++) { + if (labelName.indexOf(coreDomainLabels[cd]) !== -1) { + isExempt = true + break + } + } + if (isExempt) break + if ( + labelName.indexOf('/') !== -1 && + labelName.indexOf('Retention/') !== 0 + ) { + isExempt = true + break + } + } + + // Safety Shield 2: Check if user sent a message or replied in this thread + if (!isExempt) { + if (isThreadSentOrRepliedByUser(thread, config.userAccountEmail)) { + isExempt = true + } + } + + if (!isExempt) { + if (typeof thread.moveToTrash === 'function') { + thread.moveToTrash() + } + trashedCount++ + } else { + exemptedCount++ + pageRemainingCount++ + const subj = + typeof thread.getFirstMessageSubject === 'function' + ? thread.getFirstMessageSubject() + : thread.getId() + console.log( + '[purgeExpiredEmailsByRetentionPolicy] Exempted thread from trash (contains core domain label or user reply): ' + + subj + ) + } + } catch (err) { + pageRemainingCount++ + console.warn( + '[purgeExpiredEmailsByRetentionPolicy] Error processing thread: ' + + err.message + ) + } + } + + offset += pageRemainingCount + if (rawPage.length < pageSize) { + break + } + } + + console.log( + '[purgeExpiredEmailsByRetentionPolicy] Retention cleanup complete. Scanned ' + + totalFound + + ' thread(s); moved ' + + trashedCount + + ' expired thread(s) to Trash.' + ) + return { trashedCount: trashedCount, exemptedCount: exemptedCount } +} + +const DESTRUCTIVE_CONFIDENCE_MIN = 0.85 + +function isDestructiveActionAllowed_(classification) { + if (!classification || typeof classification !== 'object') { + return false + } + if ( + classification.action !== 'trash' && + classification.action !== 'archive' + ) { + return false + } + const confidence = classification.confidence + if (!Number.isFinite(confidence)) { + return false + } + return confidence >= DESTRUCTIVE_CONFIDENCE_MIN +} + +const GMAIL_AI_CLASSIFIER_VERSION = 'v1.8.0-drive-taxonomy-attachments' + +/** + * Main entry point for Gemini AI-Powered Semantic Email Classification and Auto-Filter Engine. + * Supports injected config and services for testing and headless execution. + * + * @param {Object} [customConfig] - Optional override config + * @param {Object} [customServices] - Optional injected services + */ +function processEmailsWithAiClassifier(customConfig, customServices) { + const services = customServices || {} + const lockService = + services.LockService || + (typeof LockService !== 'undefined' ? LockService : null) + let lock = null + if (lockService && typeof lockService.getScriptLock === 'function') { + lock = lockService.getScriptLock() + let hasLock = false + try { + hasLock = lock.tryLock(10000) + } catch (e) { + console.warn( + '[processEmailsWithAiClassifier] Error attempting script lock:', + e.message + ) + } + if (!hasLock) { + console.warn( + '[processEmailsWithAiClassifier] Could not obtain script lock after 10s. Another execution is in progress. Exiting.' + ) + return + } + } + + try { + console.log( + '[processEmailsWithAiClassifier] Engine Version: ' + + GMAIL_AI_CLASSIFIER_VERSION + ) + console.log( + '[processEmailsWithAiClassifier] Starting AI semantic email processing...' + ) + const hasExplicitCustomConfig = Boolean( + customConfig && !isTriggerEventObject_(customConfig) + ) + const config = resolveEffectiveConfig_(customConfig) + + if (!config.geminiApiKey) { + console.error( + '[processEmailsWithAiClassifier] GEMINI_API_KEY ScriptProperty is missing.' + ) + return + } + + const gmail = + services.GmailApp || (typeof GmailApp !== 'undefined' ? GmailApp : null) + if (!gmail) { + console.error( + '[processEmailsWithAiClassifier] GmailApp service is missing.' + ) + return + } + + if (!hasExplicitCustomConfig) { + revertAndQueueMissedCareerEmails(gmail, config) + } + + const rawPrimaryThreads = gmail.search(config.unprocessedQuery, 0, 10) || [] + let threads = sortThreadsByLastMessageDate_(rawPrimaryThreads) + console.log( + '[processEmailsWithAiClassifier] Found ' + + threads.length + + ' unprocessed thread(s).' + ) + + const retryLabelName = config.retryLabel || 'AI/Needs-Retry' + const manualReviewLabelName = config.manualReviewLabel || 'AI/Manual-Review' + + let isSecondarySweep = false + if (threads.length === 0) { + // Secondary sweep: Look for unread unstarred inbox threads to realign non-Primary items + const sweepQuery = + 'in:inbox is:unread -is:starred -from:me -label:' + + manualReviewLabelName + const rawSweepThreads = gmail.search(sweepQuery, 0, 15) || [] + threads = sortThreadsByLastMessageDate_(rawSweepThreads) + isSecondarySweep = true + console.log( + '[processEmailsWithAiClassifier] Secondary sweep found ' + + threads.length + + ' unread inbox thread(s) for realignment.' + ) + if (threads.length === 0) { + return + } + } + + const processedLabel = ensureGmailLabel( + config.processedLabel, + gmail, + services.Gmail + ) + const retryLabel = ensureGmailLabel(retryLabelName, gmail, services.Gmail) + const manualReviewLabel = ensureGmailLabel( + manualReviewLabelName, + gmail, + services.Gmail + ) + + const props = + (services.PropertiesService && + typeof services.PropertiesService.getScriptProperties === 'function' + ? services.PropertiesService.getScriptProperties() + : null) || + services.properties || + (typeof PropertiesService !== 'undefined' && + typeof PropertiesService.getScriptProperties === 'function' + ? PropertiesService.getScriptProperties() + : null) + + const cache = + (services.CacheService && + typeof services.CacheService.getScriptCache === 'function' + ? services.CacheService.getScriptCache() + : null) || + services.cache || + (typeof CacheService !== 'undefined' && + typeof CacheService.getScriptCache === 'function' + ? CacheService.getScriptCache() + : null) + + const startTime = Date.now() + let consecutiveRemoteFailures = 0 + let remoteQuotaExhausted = false + + if (isCircuitBreakerOpen(props)) { + const untilMs = Number.parseInt( + props.getProperty('GEMINI_BACKOFF_UNTIL'), + 10 + ) + console.warn( + '[processEmailsWithAiClassifier] Circuit breaker is OPEN until ' + + (Number.isNaN(untilMs) + ? 'unknown' + : new Date(untilMs).toISOString()) + + '; skipping remote AI calls for this execution.' + ) + remoteQuotaExhausted = true + } + + for (let i = 0; i < threads.length; i++) { + if (Date.now() - startTime > 270000) { + console.warn( + '[processEmailsWithAiClassifier] Safety time budget reached (270s); stopping batch gracefully.' + ) + break + } + const thread = threads[i] + const messages = + typeof thread.getMessages === 'function' ? thread.getMessages() : [] + if (!messages || messages.length === 0) continue + const firstMessage = messages[0] + const sender = + typeof firstMessage.getFrom === 'function' + ? firstMessage.getFrom() + : firstMessage.from || '' + const subject = + typeof firstMessage.getSubject === 'function' + ? firstMessage.getSubject() + : firstMessage.subject || '' + let snippet = + typeof firstMessage.getPlainBody === 'function' + ? firstMessage.getPlainBody().substring(0, 500) + : firstMessage.snippet || '' + snippet = enrichSnippetWithAttachments_(snippet, firstMessage) + + console.log( + '[processEmailsWithAiClassifier] Processing (' + + (i + 1) + + '/' + + threads.length + + '): ' + + subject + + ' from ' + + sender + ) + + let threadLabels = [] + if (typeof thread.getLabels === 'function') { + try { + const lList = thread.getLabels() || [] + threadLabels = lList.map(function (l) { + return typeof l.getName === 'function' ? l.getName() : String(l) + }) + } catch {} + } + const isAlreadyProcessed = + config.processedLabel && + threadLabels.indexOf(config.processedLabel) !== -1 + const isManualReview = threadLabels.indexOf(manualReviewLabelName) !== -1 + + let classification = deterministicRuleClassify_( + sender, + subject, + snippet, + thread, + config + ) + let usedRemoteApi = false + if ( + !classification && + !remoteQuotaExhausted && + !isManualReview && + (!isSecondarySweep || !isAlreadyProcessed) + ) { + const classifyFn = + services.classifyFn || + function (s, sub, sn, cfg) { + return classifyWithGemini(s, sub, sn, cfg, services) + } + classification = classifyFn(sender, subject, snippet, config) + usedRemoteApi = true + if (!classification) { + consecutiveRemoteFailures++ + recordCircuitBreakerFailure(props) + if (consecutiveRemoteFailures >= 2) { + console.warn( + '[processEmailsWithAiClassifier] Multiple consecutive remote API failures detected; disabling remote API for remainder of batch to preserve execution budget.' + ) + remoteQuotaExhausted = true + } + } else { + consecutiveRemoteFailures = 0 + recordCircuitBreakerSuccess(props) + } + } + if (classification) { + cleanConflictingLabels( + thread, + classification.canonicalDomain, + classification.subLabel, + config + ) + + let primaryTag = classification.subLabel + if (!primaryTag || isRootDomainLabel(primaryTag)) { + const domainKey = + classification.canonicalDomain || classification.canonical_label + primaryTag = + (domainKey && DEFAULT_DOMAIN_SUBLABEL_MAP[domainKey]) || + domainKey || + null + } + if (primaryTag) { + const targetLabel = ensureGmailLabel( + primaryTag, + gmail, + services.Gmail + ) + if (targetLabel) { + thread.addLabel(targetLabel) + } + + const savedAttachments = persistCanonicalAttachmentsToDrive( + thread, + classification, + config, + services + ) + if (savedAttachments && savedAttachments.hadErrors) { + const attRetryCount = incrementThreadRetryCount_(thread, cache) + if (attRetryCount >= 5 && manualReviewLabel) { + thread.addLabel(manualReviewLabel) + } else if (retryLabel) { + thread.addLabel(retryLabel) + } + console.warn( + '[processEmailsWithAiClassifier] Attachment persistence failed; queued thread for retry without marking Processed.' + ) + continue + } + + if (config.githubToken) { + try { + const notePath = getNotePathForDomain( + classification.canonicalDomain, + classification.subLabel + ) + const utils = + services.Utilities || + (typeof Utilities !== 'undefined' ? Utilities : null) + if (notePath && utils) { + const msgDate = + typeof firstMessage.getDate === 'function' + ? firstMessage.getDate() + : new Date() + const dateStr = utils.formatDate(msgDate, 'GMT', 'yyyy-MM-dd') + const entryMd = formatProgressiveDisclosureEntry( + dateStr, + classification.title || subject, + sender, + subject, + classification.summary, + config.userAccountEmail, + savedAttachments + ) + assertNoAsciiReplacement_( + (subject || '') + (classification.summary || ''), + entryMd + ) + assertClean_(entryMd, 'new entry for ' + notePath) + appendMarkdownEntryToGitHubRepo( + notePath, + entryMd, + 'feat(ingestion): ' + subject, + services + ) + } + } catch (ghErr) { + console.warn( + '[processEmailsWithAiClassifier] Non-blocking GitHub sync error for ' + + subject + + ': ' + + ghErr.message + ) + } + } + } + + if ( + classification.category && + classification.category !== 'Primary' && + classification.action !== 'trash' + ) { + classification.action = 'archive' + } + + const userSent = isThreadSentOrRepliedByUser( + thread, + config.userAccountEmail + ) + const requestedDestructive = + classification.action === 'trash' || + classification.action === 'archive' + const destructiveOk = isDestructiveActionAllowed_(classification) + if (requestedDestructive && !userSent && !destructiveOk) { + console.warn( + '[processEmailsWithAiClassifier] Shield: Skipped destructive action "' + + classification.action + + '" (low/invalid confidence ' + + classification.confidence + + '); preserved thread in INBOX.' + ) + } else if (requestedDestructive && !userSent) { + if (classification.action === 'trash') { + thread.moveToTrash() + console.log( + '[processEmailsWithAiClassifier] Action: Moved spam/unwanted thread to TRASH.' + ) + } else { + thread.moveToArchive() + console.log( + '[processEmailsWithAiClassifier] Action: Archived thread out of INBOX.' + ) + } + } + + if (classification.action === 'mark_read' || classification.markRead) { + thread.markRead() + console.log( + '[processEmailsWithAiClassifier] Action: Marked thread as READ.' + ) + } + + if (classification.category && classification.action !== 'trash') { + setGmailCategoryTab(thread, classification.category, services.Gmail) + } + + // Remove retryLabel if present + if (retryLabel && threadLabels.indexOf(retryLabelName) !== -1) { + try { + thread.removeLabel(retryLabel) + console.log( + '[processEmailsWithAiClassifier] Removed retry label from successfully classified thread.' + ) + } catch (rErr) { + console.warn( + '[processEmailsWithAiClassifier] Failed to remove retry label:', + rErr.message + ) + } + } + clearThreadRetryCount_(thread, cache) + + if (processedLabel) { + thread.addLabel(processedLabel) + } + } else { + if (usedRemoteApi) { + const retryCount = incrementThreadRetryCount_(thread, cache) + if (retryCount >= 5) { + if (retryLabel && threadLabels.indexOf(retryLabelName) !== -1) { + try { + thread.removeLabel(retryLabel) + } catch {} + } + if (manualReviewLabel) { + thread.addLabel(manualReviewLabel) + } + console.warn( + '[processEmailsWithAiClassifier] Classification failed after ' + + retryCount + + ' attempts; escalated to ' + + manualReviewLabelName + + ' for manual review.' + ) + } else { + if (retryLabel) { + thread.addLabel(retryLabel) + } + console.warn( + '[processEmailsWithAiClassifier] Classification failed or returned null; tagged with ' + + retryLabelName + + ' (attempt ' + + retryCount + + '/5) to ensure durable retry.' + ) + } + } else { + console.warn( + '[processEmailsWithAiClassifier] Remote classification skipped for thread; preserving untouched for next execution.' + ) + } + } + + if (usedRemoteApi) { + const utilsService = + services.Utilities || + (typeof Utilities !== 'undefined' ? Utilities : null) + if (utilsService && typeof utilsService.sleep === 'function') { + utilsService.sleep(2000) + } + } + } + + console.log('[processEmailsWithAiClassifier] Batch processing complete.') + } finally { + if (lock) { + try { + lock.releaseLock() + } catch { + // ignore lock release error + } + } + } +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + getAiClassifierConfig: + typeof getAiClassifierConfig !== 'undefined' + ? getAiClassifierConfig + : undefined, + getClassifierConfig: + typeof getClassifierConfig !== 'undefined' + ? getClassifierConfig + : undefined, + processEmailsWithAiClassifier, + purgeExpiredEmailsByRetentionPolicy, + isThreadSentOrRepliedByUser, + isDestructiveActionAllowed_, + validateClassification, + validateOntologicalClassification, + classifyEmailWithGemini, + buildOntologicalPrompt, + ensureGmailLabel, + ensureUserLabel: ensureGmailLabel, + createPermanentGmailFilter, + createGmailFilterRule, + processThreadBatch, + RULE6_PATTERNS, + assertClean_, + assertNoAsciiReplacement_, + extractTopicTitleFromPath, + insertEntryIntoLogSection, + formatProgressiveDisclosureEntry, + getNotePathForDomain, + appendMarkdownEntryToGitHubRepo, + executeGitHubCommit, + getSearchDateRange_, + auditClassifications, + formatAuditDigest, + auditEmailClassifications, + setupWeeklyRetentionTrigger, + setupDailyAuditTrigger, + setupFourHourAuditTrigger, + setupFiveMinuteTrigger, + stopAllTriggers, + isCanonicalClassification, + resolveTaxonomySubfolderName, + evaluateAttachmentEligibility, + isEligibleAttachment, + getMessageAttachments_, + ensureDriveTaxonomyFolder, + findDriveTaxonomyFolder_, + isDuplicateAttachment, + resolveAttachmentName, + extractZipArchiveToSubfolder, + unzipExistingDriveArchive, + persistCanonicalAttachmentsToDrive, + auditAndBackfillCanonicalAttachments, + runDryRunAttachmentAudit, + runLiveAttachmentBackfill, + setGmailCategoryTab, + cleanConflictingLabels, + reclassifyThreadsByQuery, + runRealignmentAudit, + runMissedEmailsSweep, + revertAndQueueMissedCareerEmails, + sweepAllUnreadInboxEmails, + CANONICAL_TAXONOMY_SUBFOLDERS, + SUBLABEL_TO_FOLDER_MAP, + DEFAULT_DOMAIN_SUBLABEL_MAP, + isRootDomainLabel, + resolveCanonicalDomainFromLabels, + deterministicRuleClassify_, + enrichSnippetWithAttachments_, + getFileHash, + classifyWithGemini, + extractJsonSubstring, + parseRetryDelayMs, + computeStringHash, + listAvailableGeminiModels, + isCircuitBreakerOpen, + recordCircuitBreakerFailure, + recordCircuitBreakerSuccess, + getThreadRetryCount_, + incrementThreadRetryCount_, + clearThreadRetryCount_, + isTriggerEventObject_, + resolveEffectiveConfig_, + sortThreadsByLastMessageDate_, + } } diff --git a/src/gmail-ai-classifier/tests/audit.test.js b/src/gmail-ai-classifier/tests/audit.test.js new file mode 100644 index 00000000..02fb12a7 --- /dev/null +++ b/src/gmail-ai-classifier/tests/audit.test.js @@ -0,0 +1,548 @@ +const { auditClassifications, formatAuditDigest } = require('../src/index.js') + +describe('Gmail AI Classifier Audit System', () => { + const config = { + canonicalDomains: [ + '01_Household', + '02_Finance_Legal', + '03_Vehicles', + '04_Family_Health', + '05_Tech_Infrastructure', + '06_Work_Career', + '07_Community_NonProfit', + ], + processedLabel: 'Processed', + auditDigestEmail: 'user@example.com', + } + + describe('auditClassifications', () => { + test('handles empty or non-array input gracefully', () => { + const reportNull = auditClassifications(null, config) + expect(reportNull.scannedCount).toBe(0) + expect(reportNull.flaggedCount).toBe(0) + expect(reportNull.findings).toHaveLength(0) + + const reportEmpty = auditClassifications([], config) + expect(reportEmpty.scannedCount).toBe(0) + expect(reportEmpty.flaggedCount).toBe(0) + }) + + test('passes compliant threads with no flags', () => { + const compliantThread = { + getId: () => 't1', + getFirstMessageSubject: () => 'Home HVAC Filter Replacement', + getMessages: () => [{ getFrom: () => 'service@homehvac.example.com' }], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => '01_Household' }, + ], + } + + const report = auditClassifications([compliantThread], config) + expect(report.scannedCount).toBe(1) + expect(report.flaggedCount).toBe(0) + expect(report.findings).toHaveLength(0) + }) + + test('passes compliant thread with single canonical sub-label (single-label model)', () => { + const singleLabelThread = { + getId: () => 't-single', + getFirstMessageSubject: () => 'Quarterly Statement', + getMessages: () => [{ getFrom: () => 'statements@mybank.example.com' }], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => 'Finance/Banking' }, + ], + } + + const report = auditClassifications([singleLabelThread], config) + expect(report.scannedCount).toBe(1) + expect(report.flaggedCount).toBe(0) + expect(report.findings).toHaveLength(0) + }) + + test('flags missing canonical domain (orphan processed thread)', () => { + const orphanThread = { + getId: () => 't2', + getFirstMessageSubject: () => 'General Update', + getMessages: () => [{ getFrom: () => 'info@example.com' }], + getLabels: () => [{ getName: () => 'Processed' }], + } + + const report = auditClassifications([orphanThread], config) + expect(report.scannedCount).toBe(1) + expect(report.flaggedCount).toBe(1) + expect(report.findings[0].flags).toContainEqual( + expect.stringContaining('MISSING_CANONICAL_DOMAIN') + ) + }) + + test('flags conflicting multiple domain labels', () => { + const conflictingThread = { + getId: () => 't3', + getFirstMessageSubject: () => 'Family Health Insurance Invoice', + getMessages: () => [ + { getFrom: () => 'billing@healthprovider.example.com' }, + ], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => '02_Finance_Legal' }, + { getName: () => '04_Family_Health' }, + ], + } + + const report = auditClassifications([conflictingThread], config) + expect(report.scannedCount).toBe(1) + expect(report.flaggedCount).toBe(1) + expect(report.findings[0].flags).toContainEqual( + expect.stringContaining('CONFLICTING_DOMAINS') + ) + }) + + test('does not flag conflicting domains when thread carries both domain and domain-prefixed sub-label', () => { + const threadWithSublabel = { + getId: () => 't-same-domain', + getFirstMessageSubject: () => 'Monthly Statement', + getMessages: () => [{ getFrom: () => 'statements@mybank.example.com' }], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => '02_Finance_Legal' }, + { getName: () => '02_Finance_Legal/Purchases' }, + ], + } + + const report = auditClassifications([threadWithSublabel], config) + expect(report.scannedCount).toBe(1) + expect(report.flaggedCount).toBe(0) + }) + + test('flags purchase/receipt keywords when routed to non-finance domain', () => { + const misroutedReceiptThread = { + getId: () => 't4', + getFirstMessageSubject: () => 'Your Order Confirmation #98765', + getMessages: () => [{ getFrom: () => 'orders@merchant.example.com' }], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => '07_Community_NonProfit' }, + ], + } + + const report = auditClassifications([misroutedReceiptThread], config) + expect(report.scannedCount).toBe(1) + expect(report.flaggedCount).toBe(1) + expect(report.findings[0].flags).toContainEqual( + expect.stringContaining('SUSPICIOUS_ROUTING') + ) + }) + + test('passes purchase/receipt keywords when routed to 02_Finance_Legal', () => { + const financeReceiptThread = { + getId: () => 't5', + getFirstMessageSubject: () => 'Your Order Confirmation #98765', + getMessages: () => [{ getFrom: () => 'orders@merchant.example.com' }], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => '02_Finance_Legal' }, + { getName: () => 'Finance/Purchases' }, + ], + } + + const report = auditClassifications([financeReceiptThread], config) + expect(report.scannedCount).toBe(1) + expect(report.flaggedCount).toBe(0) + }) + + test('flags promotional sales keywords without marketing sub-label', () => { + const promoThread = { + getId: () => 't6', + getFirstMessageSubject: () => 'Flash Sale! 50% Off Everything Today', + getMessages: () => [{ getFrom: () => 'deals@retailer.example.com' }], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => '01_Household' }, + ], + } + + const report = auditClassifications([promoThread], config) + expect(report.scannedCount).toBe(1) + expect(report.flaggedCount).toBe(1) + expect(report.findings[0].flags).toContainEqual( + expect.stringContaining('PROMOTIONAL_CONTENT') + ) + }) + + test('passes promotional sales keywords when marketing sub-label is present', () => { + const promoWithSublabelThread = { + getId: () => 't7', + getFirstMessageSubject: () => 'Flash Sale! 50% Off Everything Today', + getMessages: () => [{ getFrom: () => 'deals@retailer.example.com' }], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => '01_Household' }, + { getName: () => 'Household/Marketing' }, + ], + } + + const report = auditClassifications([promoWithSublabelThread], config) + expect(report.scannedCount).toBe(1) + expect(report.flaggedCount).toBe(0) + }) + + test('flags generic newsletter subjects without newsletter sub-label', () => { + const newsletterThread = { + getId: () => 't8', + getFirstMessageSubject: () => 'Weekly Digest: Top Stories in Tech', + getMessages: () => [{ getFrom: () => 'digest@technews.example.com' }], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => '05_Tech_Infrastructure' }, + ], + } + + const report = auditClassifications([newsletterThread], config) + expect(report.scannedCount).toBe(1) + expect(report.flaggedCount).toBe(1) + expect(report.findings[0].flags).toContainEqual( + expect.stringContaining('UNLABELED_NEWSLETTER') + ) + }) + + test('supports plain object thread representations', () => { + const plainThread = { + id: 't9', + subject: 'Invoice #101', + sender: 'billing@utility.example.com', + labels: ['Processed'], + } + + const report = auditClassifications([plainThread], config) + expect(report.scannedCount).toBe(1) + expect(report.flaggedCount).toBe(1) + expect(report.findings[0].flags).toContainEqual( + expect.stringContaining('MISSING_CANONICAL_DOMAIN') + ) + }) + + test('flags commercial Terms of Service / Privacy Policy update under 02_Finance_Legal', () => { + const tosThread = { + getId: () => 't-tos-1', + getFirstMessageSubject: () => 'Updates to the Waymo Terms of Service', + getMessages: () => [{ getFrom: () => 'noreply@waymo.com' }], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => '02_Finance_Legal' }, + { getName: () => 'Finance/Legal' }, + ], + } + + const report = auditClassifications([tosThread], config) + expect(report.scannedCount).toBe(1) + expect(report.flaggedCount).toBe(1) + expect(report.findings[0].flags).toContainEqual( + expect.stringContaining('COMMERCIAL_TOS_IN_LEGAL') + ) + }) + + test('does not flag compliant non-canonical processed threads (retail promos, newsletters, surveys, TOS)', () => { + const promoThread = { + getId: () => 't-promo-noncanon', + getFirstMessageSubject: () => 'Flash Sale! 50% Off Everything Today', + getMessages: () => [{ getFrom: () => 'deals@retailer.example.com' }], + getLabels: () => [{ getName: () => 'Processed' }], + } + + const newsletterThread = { + getId: () => 't-news-noncanon', + getFirstMessageSubject: () => 'Weekly Digest: Top Stories in Tech', + getMessages: () => [{ getFrom: () => 'digest@technews.example.com' }], + getLabels: () => [{ getName: () => 'Processed' }], + } + + const surveyThread = { + getId: () => 't-survey-noncanon', + getFirstMessageSubject: () => + 'How did we do? Rate your recent experience', + getMessages: () => [{ getFrom: () => 'feedback@airline.example.com' }], + getLabels: () => [{ getName: () => 'Processed' }], + } + + const tosThread = { + getId: () => 't-tos-noncanon', + getFirstMessageSubject: () => + 'Upcoming updates to our Terms of Service', + getMessages: () => [ + { getFrom: () => 'notifications@cloudservice.example.com' }, + ], + getLabels: () => [{ getName: () => 'Processed' }], + } + + const categoryThread = { + getId: () => 't-category-noncanon', + getFirstMessageSubject: () => 'New connection request', + getMessages: () => [ + { getFrom: () => 'invitations@social.example.com' }, + ], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => 'Promotions' }, + ], + } + + const report = auditClassifications( + [ + promoThread, + newsletterThread, + surveyThread, + tosThread, + categoryThread, + ], + config + ) + expect(report.scannedCount).toBe(5) + expect(report.flaggedCount).toBe(0) + expect(report.findings).toHaveLength(0) + }) + + test('does not flag trashed threads or non-canonical sender stream threads as missing domain', () => { + const trashedThread = { + getId: () => 't-trashed', + getFirstMessageSubject: () => 'Unwanted Cold Pitch', + getMessages: () => [ + { getFrom: () => 'spammer@coldoutreach.example.com' }, + ], + getLabels: () => [{ getName: () => 'Processed' }], + isInTrash: () => true, + } + + const redditThread = { + getId: () => 't-reddit', + getFirstMessageSubject: () => 'Antigravity posted in r/google', + getMessages: () => [{ getFrom: () => 'noreply@redditmail.com' }], + getLabels: () => [{ getName: () => 'Processed' }], + isInTrash: () => false, + } + + const substackThread = { + getId: () => 't-substack', + getFirstMessageSubject: () => 'Thoughts on Autumn', + getMessages: () => [ + { getFrom: () => 'writer@newsletter.substack.com' }, + ], + getLabels: () => [{ getName: () => 'Processed' }], + } + + const report = auditClassifications( + [trashedThread, redditThread, substackThread], + config + ) + expect(report.scannedCount).toBe(3) + expect(report.flaggedCount).toBe(0) + expect(report.findings).toHaveLength(0) + }) + + test('flags canonical thread archived from inbox without user action as ARCHIVED_WITHOUT_ACTION', () => { + const archivedWithoutActionThread = { + getId: () => 't-archived', + getFirstMessageSubject: () => 'Empower Cash APY Increase', + isInInbox: () => false, + isStarred: () => false, + getMessages: () => [{ getFrom: () => 'EPC@email.empower.com' }], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => '02_Finance_Legal' }, + ], + } + + const report = auditClassifications([archivedWithoutActionThread], config) + expect(report.flaggedCount).toBe(1) + expect(report.findings[0].flags[0]).toContain('ARCHIVED_WITHOUT_ACTION') + expect(report.findings[0].flags[0]).toContain( + 'candidate for secondary stream routing' + ) + }) + + test('does not flag archived thread if it already carries a secondary category label or Advanced Gmail CATEGORY_* label', () => { + const archivedWithUpdatesLabel = { + getId: () => 't-archived-updates', + getFirstMessageSubject: () => 'Empower Cash APY Increase', + isInInbox: () => false, + isStarred: () => false, + getMessages: () => [{ getFrom: () => 'EPC@email.empower.com' }], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => '02_Finance_Legal' }, + { getName: () => 'Updates' }, + ], + } + + const archivedWithAdvancedCategory = { + getId: () => 't-archived-adv-cat', + getFirstMessageSubject: () => 'Bank Statement Available', + isInInbox: () => false, + isStarred: () => false, + getMessages: () => [{ getFrom: () => 'alerts@bank.example.com' }], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => 'Finance/Banking' }, + ], + } + + const mockGmailService = { + Users: { + Threads: { + get: jest.fn((_userId, threadId) => { + if (threadId === 't-archived-adv-cat') { + return { + messages: [{ labelIds: ['CATEGORY_UPDATES'] }], + } + } + return {} + }), + }, + }, + } + + const report = auditClassifications( + [archivedWithUpdatesLabel, archivedWithAdvancedCategory], + config, + { Gmail: mockGmailService } + ) + expect(report.flaggedCount).toBe(0) + }) + + test('matches x.com on exact domain boundary and still flags orphan threads from domains ending in x.com (e.g. fedex.com)', () => { + const xComThread = { + getId: () => 't-xcom', + getFirstMessageSubject: () => 'New post notification', + getMessages: () => [{ getFrom: () => 'notify@x.com' }], + getLabels: () => [{ getName: () => 'Processed' }], + } + const fedexOrphanThread = { + getId: () => 't-fedex-orphan', + getFirstMessageSubject: () => 'Delivery Notice', + getMessages: () => [{ getFrom: () => 'tracking@fedex.com' }], + getLabels: () => [{ getName: () => 'Processed' }], + } + + const report = auditClassifications( + [xComThread, fedexOrphanThread], + config + ) + expect(report.scannedCount).toBe(2) + expect(report.flaggedCount).toBe(1) + expect(report.findings[0].threadId).toBe('t-fedex-orphan') + }) + + test('does not flag archived thread if user starred or replied to it', () => { + const starredThread = { + getId: () => 't-starred', + getFirstMessageSubject: () => 'Important Tax Notice', + isInInbox: () => false, + isStarred: () => true, + getMessages: () => [{ getFrom: () => 'tax@cpa.example.com' }], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => '02_Finance_Legal' }, + ], + } + + const repliedThread = { + getId: () => 't-replied', + getFirstMessageSubject: () => 'Contract Revision', + isInInbox: () => false, + isStarred: () => false, + getMessages: () => [ + { getFrom: () => 'lawyer@example.com' }, + { getFrom: () => 'user@example.com' }, + ], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => '02_Finance_Legal' }, + ], + } + + const report = auditClassifications([starredThread, repliedThread], { + ...config, + userAccountEmail: 'user@example.com', + }) + expect(report.flaggedCount).toBe(0) + }) + }) + + describe('formatAuditDigest', () => { + test('returns clean compliance message when no anomalies exist', () => { + const cleanReport = { + scannedCount: 15, + flaggedCount: 0, + findings: [], + summary: 'Audited 15 thread(s); 0 anomaly flag(s) identified.', + } + + const digest = formatAuditDigest(cleanReport) + expect(digest).toContain('All 15 analyzed threads are compliant') + expect(digest).toContain('No anomalies detected') + }) + + test('formats multi-item report with subjects, senders, and flags', () => { + const anomalousReport = { + scannedCount: 20, + flaggedCount: 2, + findings: [ + { + threadId: 't1', + subject: 'Flash Sale 40% Off', + sender: 'store@example.com', + canonicalLabels: ['01_Household'], + flags: [ + 'PROMOTIONAL_CONTENT: Promotional sale keywords in subject...', + ], + }, + { + threadId: 't2', + subject: 'Order Receipt', + sender: 'pay@example.com', + canonicalLabels: ['07_Community_NonProfit'], + flags: [ + 'SUSPICIOUS_ROUTING: Purchase/receipt keywords detected...', + ], + }, + ], + summary: 'Audited 20 thread(s); 2 anomaly flag(s) identified.', + } + + const digest = formatAuditDigest(anomalousReport) + expect(digest).toContain('GMAIL AI CLASSIFICATION AUDIT REPORT') + expect(digest).toContain('Flash Sale 40% Off') + expect(digest).toContain('store@example.com') + expect(digest).toContain('Order Receipt') + expect(digest).toContain('pay@example.com') + expect(digest).toContain( + 'Tuning Action: Update ScriptProperty CUSTOM_PROMPT_RULES' + ) + }) + + test('handles report with missing or malformed finding properties without throwing', () => { + const malformedReport = { + scannedCount: 1, + flaggedCount: 1, + findings: [ + { + threadId: 't-bad', + subject: null, + sender: undefined, + canonicalLabels: null, + flags: null, + }, + ], + summary: 'Audited 1 thread(s); 1 anomaly flag(s) identified.', + } + + expect(() => { + const digest = formatAuditDigest(malformedReport) + expect(digest).toContain('(no subject)') + expect(digest).toContain('(unknown)') + expect(digest).toContain('(none)') + }).not.toThrow() + }) + }) +}) diff --git a/src/gmail-ai-classifier/tests/code.test.js b/src/gmail-ai-classifier/tests/code.test.js index aac50571..c132e2ef 100644 --- a/src/gmail-ai-classifier/tests/code.test.js +++ b/src/gmail-ai-classifier/tests/code.test.js @@ -185,6 +185,39 @@ describe('ensureGmailLabel', () => { const gmailApp = makeGmailApp({ createFails: true }) expect(ensureGmailLabel('FailLabel', gmailApp)).toBeNull() }) + + test('creates label via advanced Gmail service with messageListVisibility: hide when available', () => { + const mockCreated = makeLabel('AdvancedLabel') + let advancedCall = null + const gmailAdvanced = { + Users: { + Labels: { + create: jest.fn((params, userId) => { + advancedCall = { params, userId } + return { id: 'adv-1', name: params.name } + }), + }, + }, + } + const gmailApp = { + getUserLabelByName: jest.fn((name) => { + if (advancedCall && name === 'AdvancedLabel') return mockCreated + return null + }), + createLabel: jest.fn(), + } + const label = ensureGmailLabel('AdvancedLabel', gmailApp, gmailAdvanced) + expect(label).toBe(mockCreated) + expect(gmailAdvanced.Users.Labels.create).toHaveBeenCalledWith( + { + name: 'AdvancedLabel', + labelListVisibility: 'labelShow', + messageListVisibility: 'hide', + }, + 'me' + ) + expect(gmailApp.createLabel).not.toHaveBeenCalled() + }) }) // --------------------------------------------------------------------------- @@ -198,6 +231,34 @@ describe('createPermanentGmailFilter', () => { Users: { Settings: { Filters: { create: createFn, list: listFn } } }, }) + test('returns false without throwing when senderEmail is null, undefined, or empty', () => { + const gmailApp = makeGmailApp() + expect( + createPermanentGmailFilter( + null, + '01_Household/Primary_House', + makeGmailService(), + gmailApp + ) + ).toBe(false) + expect( + createPermanentGmailFilter( + undefined, + '01_Household/Primary_House', + makeGmailService(), + gmailApp + ) + ).toBe(false) + expect( + createPermanentGmailFilter( + '', + '01_Household/Primary_House', + makeGmailService(), + gmailApp + ) + ).toBe(false) + }) + test('creates filter via Gmail service and returns true', () => { const gmailApp = makeGmailApp({ existingLabel: makeLabel('01_Household/Primary_House'), @@ -788,4 +849,4872 @@ describe('processThreadBatch', () => { expect(results[0].threadId).toBe('t1') expect(results[1].threadId).toBe('t2') }) + + test('persists attachments when services.DriveApp is provided', () => { + const thread = makeThread({ id: 't-attach' }) + const classification = { + canonical_label: '01_Household/Primary_House', + confidence: 0.98, + reasoning: 'ok', + } + const mockDriveApp = { + getRootFolder: jest.fn(() => ({ + getFoldersByName: jest.fn(() => ({ + hasNext: () => true, + next: () => ({ + getFoldersByName: jest.fn(() => ({ + hasNext: () => true, + next: () => ({ + getFilesByName: jest.fn(() => ({ hasNext: () => false })), + createFile: jest.fn(() => ({ + getId: () => 'f1', + getName: () => 'file.pdf', + getUrl: () => 'https://drive.google.com/file/d/f1', + })), + }), + })), + createFolder: jest.fn(), + }), + })), + createFolder: jest.fn(), + })), + } + const services = { + GmailApp: { + getUserLabelByName: jest.fn((name) => makeLabel(name)), + createLabel: jest.fn((name) => makeLabel(name)), + }, + UrlFetchApp: { fetch: jest.fn(() => makeGeminiResponse(classification)) }, + Gmail: null, + DriveApp: mockDriveApp, + } + + const results = processThreadBatch([thread], config, services) + expect(results).toHaveLength(1) + expect(results[0].threadId).toBe('t-attach') + expect(Array.isArray(results[0].savedAttachments)).toBe(true) + }) + + test('handles messages with plain object fallback properties without throwing', () => { + const plainMsgThread = { + id: 't-plain', + getMessages: () => [ + { + from: 'alert@apcocu.org', + subject: 'Monthly Statement Ready', + snippet: 'Your account statement is available.', + }, + ], + addLabel: jest.fn(), + getId: () => 't-plain', + } + const services = makeServices() + const results = processThreadBatch([plainMsgThread], config, services) + expect(results).toHaveLength(1) + expect(results[0].status).toBe('classified') + expect(results[0].threadId).toBe('t-plain') + }) + + test('falls back to thread.id when getId is missing', () => { + const threadWithoutGetId = { + id: 't-noid', + getMessages: () => [], + addLabel: jest.fn(), + } + const services = makeServices() + const results = processThreadBatch([threadWithoutGetId], config, services) + expect(results).toHaveLength(1) + expect(results[0].status).toBe('empty') + expect(results[0].threadId).toBe('t-noid') + }) +}) + +describe('Ontological Knowledge Graph and Triage Matrix Prompt Architecture', () => { + const codeGs = require('../code.gs') + const indexJs = require('../src/index') + const { buildOntologicalPrompt } = indexJs + + test('src/index.js and code.gs export identical function and constant keys', () => { + expect(Object.keys(indexJs).sort()).toEqual(Object.keys(codeGs).sort()) + }) + ;[ + { name: 'src/index.js', mod: indexJs }, + { name: 'code.gs', mod: codeGs }, + ].forEach(({ name, mod }) => { + describe(name, () => { + test('buildOntologicalPrompt builds 3-Tier positive invariant prompt without negative exclusions', () => { + const config = { + canonicalDomains: [ + '01_Household', + '02_Finance_Legal', + '03_Vehicles', + '04_Family_Health', + '05_Tech_Infrastructure', + '06_Work_Career', + '07_Community_NonProfit', + ], + customPromptRules: + 'entities:\n - name: "Entity Alpha"\n domain: "04_Family_Health"\n sublabel: "Family/Legal"', + } + + const prompt = mod.buildOntologicalPrompt( + config, + 'alerts@example.com', + 'Alert - Entity Alpha', + 'Summary of news mention for Entity Alpha' + ) + + // Tier 1: Positive Domain Taxonomy Ontology + expect(prompt).toContain( + '=== TIER 1: DOMAIN TAXONOMY ONTOLOGY (POSITIVE INVARIANTS) ===' + ) + expect(prompt).toContain('01_Household') + expect(prompt).toContain('02_Finance_Legal') + expect(prompt).toContain('03_Vehicles') + expect(prompt).toContain('04_Family_Health') + expect(prompt).toContain('05_Tech_Infrastructure') + expect(prompt).toContain('06_Work_Career') + expect(prompt).toContain('07_Community_NonProfit') + expect(prompt).toContain('NON-CANONICAL EMAILS (canonicalDomain: null)') + expect(prompt).toContain('Customer satisfaction surveys') + expect(prompt).toContain('Political campaign communications') + expect(prompt).toContain('equipment supply store sales') + expect(prompt).toContain( + 'enterprise daily/weekly activity digests and newsletters' + ) + + // Positive Search & Monitoring Alerts routing + expect(prompt).toContain('AUTOMATED SEARCH & MONITORING ALERTS') + expect(prompt).toContain('Google Alerts') + expect(prompt).toContain( + 'Student or school sub-labels apply only when the monitored alert query specifically targets an academic program or school' + ) + + // Tier 2: Orthogonal Triage Matrix + expect(prompt).toContain( + '=== TIER 2: ORTHOGONAL TRIAGE MATRIX (LIFECYCLE STATE) ===' + ) + expect(prompt).toContain('Action_Required') + expect(prompt).toContain('Informational_Feed') + expect(prompt).toContain('Completed_Transaction') + expect(prompt).toContain('Broadcast_Marketing') + expect(prompt).toContain('Spam_Solicitation') + expect(prompt).toContain('CATEGORY-DRIVEN INBOX RETENTION INVARIANT') + expect(prompt).toContain( + 'Primary Inbox is an Action Queue: Only emails categorized as "Primary" belong in the Primary Inbox' + ) + expect(prompt).toContain( + 'All non-Primary streams ("Updates", "Promotions", "Social", "Forums") are Informational Feeds that must be archived out of the Inbox' + ) + + // Tier 3: Injected Entity Knowledge Graph + expect(prompt).toContain( + '=== TIER 3: USER ENTITY KNOWLEDGE GRAPH & CUSTOM RULES ===' + ) + expect(prompt).toContain('Entity Alpha') + + // Constraints & Contract + expect(prompt).toContain('Single Sub-Label Invariant') + expect(prompt).toContain('Return JSON ONLY') + + // Zero negative exclusions invariant verification + expect(prompt).not.toContain('Under NO circumstances') + expect(prompt).not.toContain('Do NOT classify') + expect(prompt).not.toContain('Reserve strictly for') + }) + + test('classifyWithGemini constructs ontological prompt and sends x-goog-api-key header', () => { + let capturedPrompt = '' + let capturedUrl = '' + let capturedHeaders = null + global.UrlFetchApp = { + fetch: jest.fn((url, opts) => { + capturedUrl = url + capturedHeaders = opts.headers + const payload = JSON.parse(opts.payload) + capturedPrompt = payload.contents[0].parts[0].text + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { + text: JSON.stringify({ + canonicalDomain: '04_Family_Health', + subLabel: 'Family/Legal', + category: 'Updates', + action: 'keep', + confidence: 0.98, + title: 'Alert Title', + summary: 'Executive summary', + }), + }, + ], + }, + }, + ], + }), + } + }), + } + + const testConfig = { + canonicalDomains: ['01_Household', '04_Family_Health'], + geminiApiKey: 'test-key', + } + + const result = mod.classifyWithGemini( + 'googlealerts-noreply@google.com', + 'Google Alert - Elder Relative', + 'news summary snippet', + testConfig + ) + + expect(capturedUrl).not.toContain('key=') + expect(capturedHeaders).toEqual({ 'x-goog-api-key': 'test-key' }) + expect(capturedPrompt).toContain( + '=== TIER 1: DOMAIN TAXONOMY ONTOLOGY (POSITIVE INVARIANTS) ===' + ) + expect(capturedPrompt).toContain( + '=== TIER 2: ORTHOGONAL TRIAGE MATRIX (LIFECYCLE STATE) ===' + ) + expect(capturedPrompt).toContain('Google Alerts') + expect(capturedPrompt).not.toContain('Under NO circumstances') + expect(result).toEqual({ + canonicalDomain: '04_Family_Health', + subLabel: 'Family/Legal', + category: 'Updates', + action: 'keep', + confidence: 0.98, + title: 'Alert Title', + summary: 'Executive summary', + }) + }) + + test('classifyWithGemini returns cached classification immediately on cache hit', () => { + const cachedObj = { + canonicalDomain: '01_Household', + subLabel: 'Household/Property', + category: 'Updates', + action: 'archive', + confidence: 0.99, + title: 'Plumbing Receipt', + summary: 'Water heater repair receipt.', + } + const mockCache = { + get: jest.fn(() => JSON.stringify(cachedObj)), + put: jest.fn(), + } + const mockFetch = jest.fn() + + const result = mod.classifyWithGemini( + 'contractor@plumbing.com', + 'Invoice #1042', + 'Water heater repaired.', + { geminiApiKey: 'test-key' }, + { CacheService: mockCache, UrlFetchApp: { fetch: mockFetch } } + ) + + expect(result).toEqual(cachedObj) + expect(mockCache.get).toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + test('classifyWithGemini rejects malformed or out-of-schema response and cascades to fallback', () => { + let callCount = 0 + const mockCache = { + get: jest.fn(() => null), + put: jest.fn(), + } + const validObj = { + canonicalDomain: '01_Household', + subLabel: 'Household/Property', + category: 'Updates', + action: 'archive', + confidence: 0.96, + title: 'Valid', + summary: 'Valid summary.', + } + const mockFetch = jest.fn(() => { + callCount++ + const badOrGood = + callCount === 1 + ? { + canonicalDomain: '99_Hallucinated_Domain', + category: 'Updates', + action: 'archive', + confidence: 0.95, + } + : validObj + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + candidates: [ + { + content: { + parts: [{ text: JSON.stringify(badOrGood) }], + }, + }, + ], + }), + } + }) + + const result = mod.classifyWithGemini( + 'contractor@plumbing.com', + 'Invoice #1042', + 'Water heater repaired.', + { + geminiApiKey: 'test-key', + canonicalDomains: ['01_Household', '02_Finance_Legal'], + }, + { CacheService: mockCache, UrlFetchApp: { fetch: mockFetch } } + ) + + expect(callCount).toBe(2) + expect(result).toEqual(validObj) + }) + + test('classifyWithGemini stores result in CacheService with 21600s TTL on cache miss', () => { + const freshObj = { + canonicalDomain: '02_Finance_Legal', + subLabel: 'Finance/Banking', + category: 'Updates', + action: 'archive', + confidence: 0.95, + title: 'Monthly Statement', + summary: 'Bank statement available.', + } + const mockCache = { + get: jest.fn(() => null), + put: jest.fn(), + } + const mockFetch = jest.fn(() => ({ + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + candidates: [ + { + content: { + parts: [{ text: JSON.stringify(freshObj) }], + }, + }, + ], + }), + })) + + const result = mod.classifyWithGemini( + 'alerts@bank.com', + 'e-Statement Ready', + 'Your monthly bank statement is now ready.', + { geminiApiKey: 'test-key' }, + { CacheService: mockCache, UrlFetchApp: { fetch: mockFetch } } + ) + + expect(result).toEqual(freshObj) + expect(mockCache.get).toHaveBeenCalled() + expect(mockFetch).toHaveBeenCalledTimes(1) + expect(mockCache.put).toHaveBeenCalledWith( + expect.stringMatching(/^cls_[a-f0-9]+$/), + JSON.stringify(freshObj), + 21600 + ) + }) + + test('classifyWithGemini cascades through fallback models on HTTP 429 and records circuit breaker failure once if all fail', () => { + let callCount = 0 + const sleepMock = jest.fn() + const mockFetch = jest.fn(() => { + callCount++ + if (callCount === 1) { + return { + getResponseCode: () => 429, + getHeaders: () => ({ 'Retry-After': '2' }), + getContentText: () => + JSON.stringify({ error: { message: 'Quota exceeded' } }), + } + } + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { + text: '```json\n{"canonicalDomain": "05_Tech_Infrastructure", "subLabel": "Tech/Alerts", "category": "Updates", "action": "archive", "confidence": 0.92, "title": "Server Alert", "summary": "High CPU alert."}\n```', + }, + ], + }, + }, + ], + }), + } + }) + + const mockPropsStore = {} + const mockProps = { + getProperty: jest.fn((k) => mockPropsStore[k] || null), + setProperty: jest.fn((k, v) => { + mockPropsStore[k] = String(v) + }), + deleteProperty: jest.fn((k) => { + delete mockPropsStore[k] + }), + } + + const result = mod.classifyWithGemini( + 'alerts@cloud.com', + 'CPU Warning', + 'Server CPU at 95%', + { + modelEndpoint: + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-custom:generateContent', + geminiApiKey: 'test-key', + }, + { + UrlFetchApp: { fetch: mockFetch }, + sleepFn: sleepMock, + properties: mockProps, + } + ) + + expect(callCount).toBe(2) + expect(sleepMock).toHaveBeenCalledWith(2000) + expect(result.canonicalDomain).toBe('05_Tech_Infrastructure') + expect(mockPropsStore['GEMINI_BACKOFF_FAILURES']).toBeUndefined() + + // Now verify all endpoints returning 429 increments GEMINI_BACKOFF_FAILURES only ONCE + const mockAll429Fetch = jest.fn(() => ({ + getResponseCode: () => 429, + getHeaders: () => ({ 'Retry-After': '15' }), + getContentText: () => + JSON.stringify({ error: { message: 'Quota exceeded' } }), + })) + const failRes = mod.classifyWithGemini( + 'alerts@cloud.com', + 'CPU Warning', + 'Server CPU at 95%', + { geminiApiKey: 'test-key' }, + { + UrlFetchApp: { fetch: mockAll429Fetch }, + sleepFn: sleepMock, + properties: mockProps, + } + ) + expect(failRes).toBeNull() + expect(mockPropsStore['GEMINI_BACKOFF_FAILURES']).toBe('1') + }) + + test('extractJsonSubstring parses JSON and rescues trailing commas before closing braces/brackets', () => { + const rawWithTrailingCommas = + '```json\n{\n "canonicalDomain": "01_Household",\n "subLabel": "Household/Property",\n "items": [1, 2, ],\n}\n```' + const parsed = mod.extractJsonSubstring(rawWithTrailingCommas) + expect(parsed).toEqual({ + canonicalDomain: '01_Household', + subLabel: 'Household/Property', + items: [1, 2], + }) + }) + + test('parseRetryDelayMs parses Retry-After header and clamps between 1000ms and 30000ms', () => { + const mockResponse1 = { + getHeaders: () => ({ 'Retry-After': '12' }), + getContentText: () => '', + } + expect(mod.parseRetryDelayMs(mockResponse1)).toBe(12000) + + const mockResponseMax = { + getHeaders: () => ({ 'Retry-After': '90' }), + getContentText: () => '', + } + expect(mod.parseRetryDelayMs(mockResponseMax)).toBe(30000) + + const mockResponseDetail = { + getHeaders: () => ({}), + getContentText: () => + JSON.stringify({ + error: { + details: [{ retryDelay: '3.5s' }], + }, + }), + } + expect(mod.parseRetryDelayMs(mockResponseDetail)).toBe(3500) + }) + + test('computeStringHash generates stable hex digest', () => { + const hash1 = mod.computeStringHash('test string') + const hash2 = mod.computeStringHash('test string') + const hash3 = mod.computeStringHash('different string') + expect(hash1).toBe(hash2) + expect(hash1).not.toBe(hash3) + expect(hash1).toMatch(/^[a-f0-9]{32}$/) + }) + + test('getNotePathForDomain supports CUSTOM_NOTE_PATHS from PropertiesService', () => { + const savedProps = global.PropertiesService + try { + expect(mod.getNotePathForDomain('04_Family_Health')).toBe( + '04_Family_Health/index.md' + ) + + global.PropertiesService = { + getScriptProperties: () => ({ + getProperty: (key) => { + if (key === 'CUSTOM_NOTE_PATHS') { + return JSON.stringify({ + '04_Family_Health': 'custom-domain/kids/index.md', + }) + } + return null + }, + }), + } + + expect(mod.getNotePathForDomain('04_Family_Health')).toBe( + 'custom-domain/kids/index.md' + ) + } finally { + global.PropertiesService = savedProps + } + }) + }) + }) + + test('buildOntologicalPrompt includes telemetry and school/student positive invariants', () => { + const config = { + canonicalDomains: [ + '04_Family_Health', + '05_Tech_Infrastructure', + '07_Community_NonProfit', + ], + } + const prompt = buildOntologicalPrompt( + config, + 'noreply@parentsquad.com', + 'Weekly Newsletter', + 'School news and lunch menu' + ) + expect(prompt).toContain('AUTOMATED MACHINE & SENSOR TELEMETRY') + expect(prompt).toContain('BroodMinder') + expect(prompt).toContain('Projects/Telemetry') + expect(prompt).toContain('Tech/Alerts') + expect(prompt).toContain( + 'Always route machine telemetry to category "Updates" with action "archive"' + ) + expect(prompt).toContain( + 'SCHOOL & STUDENT ANNOUNCEMENTS VS DIRECT CORRESPONDENCE' + ) + expect(prompt).toContain('MCAA') + expect(prompt).toContain('Briarwood') + expect(prompt).toContain('FACTS') + expect(prompt).toContain('PowerSchool') + expect(prompt).toContain('ParentSquare') + expect(prompt).toContain('Google Classroom') + expect(prompt).toContain('Family/School-Student') + expect(prompt).toContain('PRIMARY PARTY ATTRIBUTION PRINCIPLE') + expect(prompt).toContain('Family/Kids/Tide') + expect(prompt).toContain('Family/Kids/Toby') + expect(prompt).toContain( + 'NON-PROFIT & CHARITY BROADCASTS VS DIRECT CORRESPONDENCE' + ) + expect(prompt).toContain( + 'TECH PLATFORM NOTIFICATIONS & SYSTEM ANNOUNCEMENTS' + ) + expect(prompt).not.toContain('Under NO circumstances') + expect(prompt).not.toContain('Do NOT classify') + expect(prompt).not.toContain('Reserve strictly for') + }) +}) + +describe('setGmailCategoryTab Category Shifting', () => { + const { setGmailCategoryTab: setCatIndex } = require('../src/index') + const { setGmailCategoryTab: setCatCodeGs } = require('../code.gs') + + ;[ + { name: 'src/index.js implementation', fn: setCatIndex }, + { name: 'code.gs implementation', fn: setCatCodeGs }, + ].forEach(({ name, fn }) => { + describe(name, () => { + let mockModify + let mockGmailService + let mockThread + + beforeEach(() => { + mockModify = jest.fn() + mockGmailService = { + Users: { + Threads: { + modify: mockModify, + }, + }, + } + mockThread = { + getId: () => 'thread_123', + getFirstMessageSubject: () => 'Test Subject', + } + }) + + test('assigns Updates category and strips other categories', () => { + fn(mockThread, 'Updates', mockGmailService) + expect(mockModify).toHaveBeenCalledWith( + { + addLabelIds: ['CATEGORY_UPDATES'], + removeLabelIds: [ + 'CATEGORY_PERSONAL', + 'CATEGORY_PROMOTIONS', + 'CATEGORY_SOCIAL', + 'CATEGORY_FORUMS', + ], + }, + 'me', + 'thread_123' + ) + }) + + test('assigns Primary category and strips other categories', () => { + fn(mockThread, 'Primary', mockGmailService) + expect(mockModify).toHaveBeenCalledWith( + { + addLabelIds: ['CATEGORY_PERSONAL'], + removeLabelIds: [ + 'CATEGORY_UPDATES', + 'CATEGORY_PROMOTIONS', + 'CATEGORY_SOCIAL', + 'CATEGORY_FORUMS', + ], + }, + 'me', + 'thread_123' + ) + }) + + test('assigns Social category and strips other categories', () => { + fn(mockThread, 'Social', mockGmailService) + expect(mockModify).toHaveBeenCalledWith( + { + addLabelIds: ['CATEGORY_SOCIAL'], + removeLabelIds: [ + 'CATEGORY_PERSONAL', + 'CATEGORY_UPDATES', + 'CATEGORY_PROMOTIONS', + 'CATEGORY_FORUMS', + ], + }, + 'me', + 'thread_123' + ) + }) + + test('handles unknown category gracefully as no-op', () => { + fn(mockThread, 'UnknownCategory', mockGmailService) + expect(mockModify).not.toHaveBeenCalled() + }) + + test('handles missing or disabled Advanced Gmail API gracefully', () => { + expect(() => { + fn(mockThread, 'Updates', null) + }).not.toThrow() + expect(mockModify).not.toHaveBeenCalled() + }) + + test('handles thread without getFirstMessageSubject safely', () => { + const bareThread = { + getId: () => 'bare_thread_456', + } + expect(() => { + fn(bareThread, 'Updates', mockGmailService) + }).not.toThrow() + expect(mockModify).toHaveBeenCalledWith( + expect.objectContaining({ + addLabelIds: ['CATEGORY_UPDATES'], + }), + 'me', + 'bare_thread_456' + ) + }) + + test('handles null or invalid thread gracefully without throwing', () => { + expect(() => { + fn(null, 'Updates', mockGmailService) + }).not.toThrow() + expect(() => { + fn({}, 'Updates', mockGmailService) + }).not.toThrow() + expect(mockModify).not.toHaveBeenCalled() + }) + }) + }) +}) + +describe('cleanConflictingLabels Sub-Label Cleansing', () => { + const { cleanConflictingLabels: cleanIndex } = require('../src/index') + const { cleanConflictingLabels: cleanCodeGs } = require('../code.gs') + + ;[ + { name: 'src/index.js implementation', fn: cleanIndex }, + { name: 'code.gs implementation', fn: cleanCodeGs }, + ].forEach(({ name, fn }) => { + describe(name, () => { + test('strips conflicting sub-labels such as Family/Sisters/Kristien when primary is Family/Kids/Tide', () => { + const removed = [] + const mockLabels = [ + { getName: () => 'Family/Sisters/Kristien' }, + { getName: () => '04_Family_Health' }, + { getName: () => 'Retention/Permanent' }, + { getName: () => 'Processed' }, + { getName: () => 'Retention/30d' }, + { getName: () => 'Archives/2026' }, + ] + const mockThread = { + getLabels: () => mockLabels, + removeLabel: jest.fn((lObj) => { + removed.push(lObj.getName()) + }), + } + const config = { + canonicalDomains: ['04_Family_Health'], + processedLabel: 'Processed', + } + + fn(mockThread, '04_Family_Health', 'Family/Kids/Tide', config) + + // Conflicting sub-label Family/Sisters/Kristien MUST be removed + expect(removed).toContain('Family/Sisters/Kristien') + // Domain code 04_Family_Health removed when targetSubLabel is set + expect(removed).toContain('04_Family_Health') + // Redundant Retention/Permanent removed + expect(removed).toContain('Retention/Permanent') + // Protected labels MUST NOT be removed + expect(removed).not.toContain('Processed') + expect(removed).not.toContain('Retention/30d') + expect(removed).not.toContain('Archives/2026') + }) + + test('strips legacy flat labels like Family and Household', () => { + const removed = [] + const mockLabels = [ + { getName: () => 'Family' }, + { getName: () => 'Household' }, + ] + const mockThread = { + getLabels: () => mockLabels, + removeLabel: jest.fn((lObj) => { + removed.push(lObj.getName()) + }), + } + const config = { canonicalDomains: [] } + + fn(mockThread, '04_Family_Health', 'Family/Kids/Tide', config) + + expect(removed).toContain('Family') + expect(removed).toContain('Household') + }) + + test('unconditionally strips root domain codes even when targetSubLabel is null', () => { + const removed = [] + const mockLabels = [ + { getName: () => '04_Family_Health' }, + { getName: () => '01_Household' }, + { getName: () => '05_Tech_Infrastructure' }, + ] + const mockThread = { + getLabels: () => mockLabels, + removeLabel: jest.fn((lObj) => { + removed.push(lObj.getName()) + }), + } + const config = { + canonicalDomains: [ + '01_Household', + '04_Family_Health', + '05_Tech_Infrastructure', + ], + } + + fn(mockThread, '04_Family_Health', null, config) + + expect(removed).toContain('04_Family_Health') + expect(removed).toContain('01_Household') + expect(removed).toContain('05_Tech_Infrastructure') + }) + + test('safely handles null/undefined config and string label objects without throwing', () => { + const removed = [] + const mockLabels = ['Family', '01_Household', 'Processed'] + const mockThread = { + getLabels: () => mockLabels, + removeLabel: jest.fn((lObj) => { + removed.push( + typeof lObj.getName === 'function' ? lObj.getName() : String(lObj) + ) + }), + } + + expect(() => { + fn(mockThread, '01_Household', 'Household/General', null) + }).not.toThrow() + expect(removed).toContain('Family') + expect(removed).toContain('01_Household') + expect(removed).not.toContain('Processed') + }) + + test('safely handles null thread without throwing or aborting', () => { + expect(() => { + fn(null, '01_Household', 'Household/General', null) + }).not.toThrow() + }) + + test('continues removing other labels even if one label removal throws', () => { + const removed = [] + const mockLabels = [ + { getName: () => '01_Household' }, + { getName: () => 'Finance/Purchases' }, + ] + const mockThread = { + getLabels: () => mockLabels, + removeLabel: jest.fn((lObj) => { + if (lObj.getName() === '01_Household') throw new Error('API locked') + removed.push(lObj.getName()) + }), + } + fn(mockThread, '02_Finance_Legal', 'Finance/Banking', { + canonicalDomains: ['01_Household'], + }) + expect(removed).toContain('Finance/Purchases') + }) + }) + }) +}) + +describe('DEFAULT_DOMAIN_SUBLABEL_MAP and isRootDomainLabel', () => { + const { + DEFAULT_DOMAIN_SUBLABEL_MAP: mapIndex, + isRootDomainLabel: isRootIndex, + } = require('../src/index') + const { + DEFAULT_DOMAIN_SUBLABEL_MAP: mapCodeGs, + isRootDomainLabel: isRootCodeGs, + } = require('../code.gs') + + ;[ + { name: 'src/index.js', map: mapIndex, isRoot: isRootIndex }, + { name: 'code.gs', map: mapCodeGs, isRoot: isRootCodeGs }, + ].forEach(({ name, map, isRoot }) => { + describe(name, () => { + test('maps all 7 core domains to canonical sublabels', () => { + expect(map['01_Household']).toBe('Household/General') + expect(map['02_Finance_Legal']).toBe('Finance/Banking') + expect(map['03_Vehicles']).toBe('Vehicles/General') + expect(map['04_Family_Health']).toBe('Family/General') + expect(map['05_Tech_Infrastructure']).toBe('Tech/Infrastructure') + expect(map['06_Work_Career']).toBe('Work/General') + expect(map['07_Community_NonProfit']).toBe('Community/NonProfit') + }) + + test('correctly identifies root domain labels vs sub-labels', () => { + expect(isRoot('01_Household')).toBe(true) + expect(isRoot('05_Tech_Infrastructure')).toBe(true) + expect(isRoot('Household/General')).toBe(false) + expect(isRoot('Tech/Infrastructure')).toBe(false) + expect(isRoot('01_Household/Primary_House')).toBe(false) + expect(isRoot(null)).toBe(false) + expect(isRoot('')).toBe(false) + }) + }) + }) +}) + +describe('Sparse Body Attachment Filename Enrichment', () => { + const { processThreadBatch } = require('../src/index') + + test('enriches snippet with attachment filenames when body is sparse', () => { + let capturedPrompt = '' + const mockThread = { + getId: () => 't-sparse', + getMessages: () => [ + { + getFrom: () => 'sender@example.com', + getSubject: () => 'Fwd: scan', + getPlainBody: () => ' ', + getAttachments: () => [ + { getName: () => 'Receipt_2026.pdf' }, + { getName: () => 'Contract_Doc.pdf' }, + ], + }, + ], + getLabels: () => [], + addLabel: jest.fn(), + } + const mockFetch = jest.fn((url, opts) => { + const payload = JSON.parse(opts.payload) + capturedPrompt = payload.contents[0].parts[0].text + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { + text: JSON.stringify({ + canonical_label: '02_Finance_Legal/Purchases', + canonicalDomain: '02_Finance_Legal', + subLabel: 'Finance/Purchases', + confidence: 0.95, + reasoning: 'Attached receipt document', + }), + }, + ], + }, + }, + ], + }), + } + }) + const config = { + modelEndpoint: 'https://example.com/gemini', + geminiApiKey: 'test-key', + canonicalDomains: ['02_Finance_Legal/Purchases'], + processedLabel: 'Processed', + autoFilterConfidenceThreshold: 0.99, + } + const services = { + GmailApp: { + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + createLabel: jest.fn((name) => ({ getName: () => name })), + }, + UrlFetchApp: { fetch: mockFetch }, + Gmail: null, + } + + processThreadBatch([mockThread], config, services) + + expect(capturedPrompt).toContain( + '[Attached: Receipt_2026.pdf, Contract_Doc.pdf]' + ) + }) + + test('enriches snippet with attachment filenames even when body is not sparse', () => { + let capturedPrompt = '' + const mockThread = { + getId: () => 't-non-sparse', + getMessages: () => [ + { + getFrom: () => 'contractor@example.com', + getSubject: () => 'Roof estimate for review', + getPlainBody: () => + 'Hi Client, please find attached the detailed estimate for the residential porch and roof repair project.', + getAttachments: () => [ + { getName: () => 'Roof_Estimate_2026.pdf' }, + { getName: () => 'company_logo.png' }, + ], + }, + ], + getLabels: () => [], + addLabel: jest.fn(), + } + const mockFetch = jest.fn((url, opts) => { + const payload = JSON.parse(opts.payload) + capturedPrompt = payload.contents[0].parts[0].text + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { + text: JSON.stringify({ + canonical_label: '01_Household/Property', + canonicalDomain: '01_Household', + subLabel: 'Household/Property', + confidence: 0.98, + reasoning: 'Roof repair estimate', + }), + }, + ], + }, + }, + ], + }), + } + }) + const config = { + modelEndpoint: 'https://example.com/gemini', + geminiApiKey: 'test-key', + canonicalDomains: ['01_Household/Property'], + processedLabel: 'Processed', + autoFilterConfidenceThreshold: 0.99, + } + const services = { + GmailApp: { + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + createLabel: jest.fn((name) => ({ getName: () => name })), + }, + UrlFetchApp: { fetch: mockFetch }, + Gmail: null, + } + + processThreadBatch([mockThread], config, services) + + expect(capturedPrompt).toContain('[Attached: Roof_Estimate_2026.pdf]') + expect(capturedPrompt).not.toContain('company_logo.png') + }) +}) + +describe('enrichSnippetWithAttachments_ Unit Tests', () => { + const { enrichSnippetWithAttachments_: enrichIndex } = require('../src/index') + const { enrichSnippetWithAttachments_: enrichCodeGs } = require('../code.gs') + + ;[ + { name: 'src/index.js', fn: enrichIndex }, + { name: 'code.gs', fn: enrichCodeGs }, + ].forEach(({ name, fn }) => { + describe(`${name} implementation`, () => { + test('returns base snippet untouched when message has no attachments', () => { + const msg = { getAttachments: () => [] } + expect(fn('Hello world', msg)).toBe('Hello world') + expect(fn('', msg)).toBe('') + expect(fn(null, msg)).toBe('') + }) + + test('appends document attachment names to non-empty snippet', () => { + const msg = { + getAttachments: () => [ + { getName: () => 'Inspection_Report.pdf' }, + { getName: () => 'Invoice.xlsx' }, + ], + } + const result = fn('Please review findings.', msg) + expect(result).toBe( + 'Please review findings. [Attached: Inspection_Report.pdf, Invoice.xlsx]' + ) + }) + + test('enriches sparse/empty snippet with attachment names', () => { + const msg = { + getAttachments: () => [{ getName: () => 'Signed_Waiver.pdf' }], + } + expect(fn('', msg)).toBe('[Attached: Signed_Waiver.pdf]') + expect(fn(' ', msg)).toBe('[Attached: Signed_Waiver.pdf]') + }) + + test('filters signature icons, tracking pixels, and non-document formats', () => { + const msg = { + getAttachments: () => [ + { getName: () => 'logo.png' }, + { getName: () => 'company_logo.jpeg' }, + { getName: () => 'signature_banner.jpg' }, + { getName: () => 'spacer.gif' }, + { getName: () => 'invite.ics' }, + { getName: () => 'contact.vcf' }, + { getName: () => 'disclaimer.htm' }, + { getName: () => 'Valid_Contract.docx' }, + ], + } + const result = fn('Agreement attached.', msg) + expect(result).toBe( + 'Agreement attached. [Attached: Valid_Contract.docx]' + ) + }) + + test('is idempotent when [Attached: already present in snippet', () => { + const msg = { + getAttachments: () => [{ getName: () => 'Existing.pdf' }], + } + const snippet = 'Here is the doc. [Attached: Existing.pdf]' + expect(fn(snippet, msg)).toBe(snippet) + }) + }) + }) +}) + +describe('reclassifyThreadsByQuery Historical Realignment', () => { + const { + reclassifyThreadsByQuery: reclassifyIndex, + runRealignmentAudit: auditIndex, + sweepAllUnreadInboxEmails: sweepIndex, + } = require('../src/index') + const { + reclassifyThreadsByQuery: reclassifyCodeGs, + runRealignmentAudit: auditCodeGs, + sweepAllUnreadInboxEmails: sweepCodeGs, + } = require('../code.gs') + + ;[ + { + name: 'src/index.js implementation', + fn: reclassifyIndex, + auditFn: auditIndex, + sweepFn: sweepIndex, + }, + { + name: 'code.gs implementation', + fn: reclassifyCodeGs, + auditFn: auditCodeGs, + sweepFn: sweepCodeGs, + }, + ].forEach(({ name, fn, auditFn, sweepFn }) => { + describe(name, () => { + test('reclassifies historical threads and strips conflicting labels in live mode', () => { + const addedLabels = [] + const removedLabels = [] + const mockLabels = [{ getName: () => 'Family/Sisters/Kristien' }] + const mockThread = { + getId: () => 'thread-xyz', + getMessages: () => [ + { + getFrom: () => 'probate@example.com', + getSubject: () => 'Name Change Decree', + getPlainBody: () => 'Order regarding Tide name change petition.', + }, + ], + getLabels: () => mockLabels, + addLabel: jest.fn((l) => + addedLabels.push( + typeof l.getName === 'function' ? l.getName() : String(l) + ) + ), + removeLabel: jest.fn((l) => + removedLabels.push( + typeof l.getName === 'function' ? l.getName() : String(l) + ) + ), + } + const mockGmail = { + search: jest.fn(() => [mockThread]), + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + createLabel: jest.fn((name) => ({ getName: () => name })), + } + const mockClassify = jest.fn(() => ({ + canonicalDomain: '04_Family_Health', + subLabel: 'Family/Kids/Tide', + action: 'keep', + category: 'Primary', + })) + + const report = fn( + 'label:"Family/Sisters" Tide', + { maxThreads: 10, dryRun: false }, + { processedLabel: 'Processed' }, + { GmailApp: mockGmail, classifyFn: mockClassify } + ) + + expect(report.scanned).toBe(1) + expect(report.reclassified).toBe(1) + expect(report.items[0].newSubLabel).toBe('Family/Kids/Tide') + expect(removedLabels).toContain('Family/Sisters/Kristien') + expect(addedLabels).toContain('Family/Kids/Tide') + expect(addedLabels).toContain('Processed') + }) + + test('does not modify thread labels when dryRun is true', () => { + const addedLabels = [] + const removedLabels = [] + const mockLabels = [{ getName: () => 'Family/Sisters/Kristien' }] + const mockThread = { + getId: () => 'thread-dry', + getMessages: () => [ + { + getFrom: () => 'probate@example.com', + getSubject: () => 'Tide Document', + getPlainBody: () => 'Tide paperwork details', + }, + ], + getLabels: () => mockLabels, + addLabel: jest.fn((l) => addedLabels.push(l)), + removeLabel: jest.fn((l) => removedLabels.push(l)), + } + const mockGmail = { + search: jest.fn(() => [mockThread]), + } + const mockClassify = jest.fn(() => ({ + canonicalDomain: '04_Family_Health', + subLabel: 'Family/Kids/Tide', + action: 'keep', + category: 'Primary', + })) + + const report = fn( + 'label:"Family/Sisters" Tide', + { maxThreads: 10, dryRun: true }, + { processedLabel: 'Processed' }, + { GmailApp: mockGmail, classifyFn: mockClassify } + ) + + expect(report.scanned).toBe(1) + expect(report.dryRun).toBe(true) + expect(addedLabels).toHaveLength(0) + expect(removedLabels).toHaveLength(0) + }) + + test('detects tldChanged and relocates attachments via file.moveTo', () => { + const mockMovedFiles = [] + const mockFile = { + getName: () => 'decree.pdf', + moveTo: jest.fn((destFolder) => { + mockMovedFiles.push({ + file: 'decree.pdf', + dest: destFolder.getName(), + }) + }), + } + const mockOldFolder = { + getName: () => 'Banking', + getFilesByName: jest.fn((_name) => ({ + hasNext: jest.fn().mockReturnValueOnce(true).mockReturnValue(false), + next: jest.fn(() => mockFile), + })), + } + const mockNewFolder = { + getName: () => 'Students', + getFilesByName: jest.fn(() => ({ + hasNext: () => false, + })), + createFile: jest.fn(), + } + const mockDrive = { + getRootFolder: () => ({ + getFoldersByName: (_name) => ({ + hasNext: () => true, + next: () => ({ + getFoldersByName: (subName) => ({ + hasNext: () => true, + next: () => + subName === 'Students' ? mockNewFolder : mockOldFolder, + }), + createFolder: (subName) => + subName === 'Students' ? mockNewFolder : mockOldFolder, + }), + }), + createFolder: () => ({ + getFoldersByName: () => ({ + hasNext: () => true, + next: () => mockNewFolder, + }), + }), + }), + } + + const mockAttachment = { + getName: () => 'decree.pdf', + getContentType: () => 'application/pdf', + getSize: () => 15000, + getBytes: () => new Uint8Array([1, 2, 3]), + } + + const mockThread = { + getId: () => 'thread-tld-change', + getMessages: () => [ + { + getFrom: () => 'probate@court.gov', + getSubject: () => 'Order Granting Petition', + getPlainBody: () => 'Legal decree details.', + getAttachments: () => [mockAttachment], + }, + ], + getLabels: () => [ + { getName: () => '02_Finance_Legal' }, + { getName: () => 'Finance/Banking' }, + ], + addLabel: jest.fn(), + removeLabel: jest.fn(), + } + + const mockGmail = { + search: jest.fn(() => [mockThread]), + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + createLabel: jest.fn((name) => ({ getName: () => name })), + } + const mockClassify = jest.fn(() => ({ + canonicalDomain: '04_Family_Health', + subLabel: 'Family/Kids/Tide', + action: 'keep', + category: 'Primary', + })) + + const report = fn( + 'Tide Name Change', + { maxThreads: 5, dryRun: false }, + { + canonicalDomains: ['02_Finance_Legal', '04_Family_Health'], + processedLabel: 'Processed', + }, + { + GmailApp: mockGmail, + DriveApp: mockDrive, + classifyFn: mockClassify, + getFileHash: () => 'hash123', + } + ) + + expect(report.scanned).toBe(1) + expect(report.items[0].tldChanged).toBe(true) + expect(report.items[0].oldDomain).toBe('02_Finance_Legal') + expect(report.items[0].newDomain).toBe('04_Family_Health') + expect(mockFile.moveTo).toHaveBeenCalledWith(mockNewFolder) + expect(mockMovedFiles).toHaveLength(1) + expect(mockMovedFiles[0].dest).toBe('Students') + }) + + test('deterministically sets action archive and calls moveToArchive for non-Primary categories', () => { + const mockMoveToArchive = jest.fn() + const mockThread = { + getId: () => 'thread-nonprimary', + getMessages: () => [ + { + getFrom: () => 'newsletter@school.org', + getSubject: () => 'Weekly Update', + getPlainBody: () => 'Routine school announcements', + }, + ], + getLabels: () => [], + addLabel: jest.fn(), + removeLabel: jest.fn(), + moveToArchive: mockMoveToArchive, + } + const mockGmail = { + search: jest.fn(() => [mockThread]), + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + createLabel: jest.fn((name) => ({ getName: () => name })), + } + const mockClassify = jest.fn(() => ({ + canonicalDomain: '04_Family_Health', + subLabel: 'Family/School-Student', + action: 'keep', + category: 'Updates', + confidence: 0.95, + })) + + const report = fn( + 'school newsletter', + { maxThreads: 5, dryRun: false }, + { + canonicalDomains: ['04_Family_Health'], + processedLabel: 'Processed', + }, + { + GmailApp: mockGmail, + classifyFn: mockClassify, + } + ) + + expect(report.scanned).toBe(1) + expect(report.items[0].action).toBe('archive') + expect(mockMoveToArchive).toHaveBeenCalledTimes(1) + }) + + test('reclassifyThreadsByQuery shields user-sent/replied threads and low-confidence destructive actions', () => { + const mockArchiveSent = jest.fn() + const mockTrashLowConf = jest.fn() + const userSentThread = { + getId: () => 'th-user-sent', + getMessages: () => [ + { + getFrom: () => 'sender@example.com', + getSubject: () => 'Update', + getPlainBody: () => 'Body', + }, + { + getFrom: () => 'User <owner@example.com>', + getSubject: () => 'Re: Update', + getPlainBody: () => 'Reply', + }, + ], + getLabels: () => [], + addLabel: jest.fn(), + removeLabel: jest.fn(), + moveToArchive: mockArchiveSent, + } + const lowConfThread = { + getId: () => 'th-low-conf', + getMessages: () => [ + { + getFrom: () => 'promo@example.com', + getSubject: () => 'Promo', + getPlainBody: () => 'Body', + }, + ], + getLabels: () => [], + addLabel: jest.fn(), + removeLabel: jest.fn(), + moveToTrash: mockTrashLowConf, + } + const mockGmail = { + search: jest.fn(() => [userSentThread, lowConfThread]), + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + createLabel: jest.fn((name) => ({ getName: () => name })), + } + let callIdx = 0 + const mockClassify = jest.fn(() => { + callIdx++ + if (callIdx === 1) { + return { + canonicalDomain: '01_Household', + subLabel: 'Household/General', + action: 'archive', + category: 'Updates', + confidence: 0.95, + } + } + return { + canonicalDomain: null, + subLabel: null, + action: 'trash', + category: 'Promotions', + confidence: 0.5, + } + }) + + const report = fn( + 'test query', + { maxThreads: 5, dryRun: false }, + { + canonicalDomains: ['01_Household'], + processedLabel: 'Processed', + userAccountEmail: 'owner@example.com', + }, + { + GmailApp: mockGmail, + classifyFn: mockClassify, + } + ) + + expect(report.scanned).toBe(2) + expect(mockArchiveSent).not.toHaveBeenCalled() + expect(mockTrashLowConf).not.toHaveBeenCalled() + }) + + it('dynamically discovers sister labels and includes name change query in runRealignmentAudit', () => { + const mockLabels = [ + { getName: () => 'Family/Sisters/SubSister' }, + { getName: () => 'Finance/Banking' }, + ] + + const mockGmail = { + getUserLabels: jest.fn(() => mockLabels), + search: jest.fn(() => []), + } + + const report = auditFn( + null, + { maxThreads: 10 }, + { canonicalDomains: ['04_Family_Health'] }, + { GmailApp: mockGmail, classifyFn: jest.fn() } + ) + + expect(mockGmail.getUserLabels).toHaveBeenCalled() + expect(mockGmail.search).toHaveBeenCalledWith( + 'label:"Family/Sisters/SubSister" OR "name change"', + 0, + 10 + ) + expect(report.dryRun).toBe(true) + }) + + test('safely handles missing services.Utilities when global Utilities is undefined', () => { + const originalUtilities = global.Utilities + delete global.Utilities + try { + const mockThread = { + getId: () => 'thread-util-test', + getMessages: () => [ + { + getFrom: () => 'test@example.com', + getSubject: () => 'Doc Test', + getPlainBody: () => 'Test body', + getAttachments: () => [], + }, + ], + getLabels: () => [], + addLabel: jest.fn(), + removeLabel: jest.fn(), + moveToArchive: jest.fn(), + } + const mockGmail = { + search: jest.fn(() => [mockThread]), + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + createLabel: jest.fn((name) => ({ getName: () => name })), + } + const mockClassify = jest.fn(() => ({ + canonicalDomain: '01_Household', + subLabel: 'Household/General', + action: 'keep', + category: 'Primary', + })) + const mockDrive = { + getRootFolder: () => ({ + getFoldersByName: jest.fn(() => ({ + hasNext: () => false, + })), + createFolder: jest.fn(() => ({ + getFoldersByName: jest.fn(() => ({ hasNext: () => false })), + createFolder: jest.fn(), + getFilesByName: jest.fn(() => ({ hasNext: () => false })), + createFile: jest.fn(), + })), + }), + } + + expect(() => { + fn( + 'test query', + { maxThreads: 1, dryRun: false }, + { canonicalDomains: ['01_Household'] }, + { + GmailApp: mockGmail, + DriveApp: mockDrive, + classifyFn: mockClassify, + } + ) + }).not.toThrow() + } finally { + global.Utilities = originalUtilities + } + }) + + test('resolves oldDomain and detects tldChanged when historical thread carries only a sub-label', () => { + const mockThread = { + getId: () => 't-single-sublabel', + getMessages: () => [ + { + getFrom: () => 'partner@example.com', + getSubject: () => 'Invoice Note', + getPlainBody: () => 'Invoice details', + getAttachments: () => [], + }, + ], + getLabels: () => [ + { getName: () => 'Finance/Banking' }, + { getName: () => 'Processed' }, + ], + addLabel: jest.fn(), + removeLabel: jest.fn(), + moveToArchive: jest.fn(), + } + const mockGmail = { + search: jest.fn(() => [mockThread]), + } + const mockClassify = jest.fn(() => ({ + canonicalDomain: '01_Household', + subLabel: 'Household/Property', + action: 'archive', + category: 'Updates', + })) + + const report = fn( + 'test query', + { maxThreads: 1, dryRun: true }, + { canonicalDomains: ['01_Household', '02_Finance_Legal'] }, + { + GmailApp: mockGmail, + classifyFn: mockClassify, + } + ) + + expect(report.scanned).toBe(1) + expect(report.items[0].oldDomain).toBe('02_Finance_Legal') + expect(report.items[0].newDomain).toBe('01_Household') + expect(report.items[0].tldChanged).toBe(true) + }) + + test('reclassifyThreadsByQuery uses deterministicRuleClassify_ prior to remote classifyFn', () => { + const mockThread = { + getId: () => 't-deterministic', + getMessages: () => [ + { + getFrom: () => 'security@example.com', + getSubject: () => 'Your login code is 123456', + getPlainBody: () => 'Enter this code to complete sign in.', + getAttachments: () => [], + }, + ], + getLabels: () => [{ getName: () => 'Processed' }], + addLabel: jest.fn(), + removeLabel: jest.fn(), + moveToArchive: jest.fn(), + } + const mockGmail = { + search: jest.fn(() => [mockThread]), + } + const mockClassify = jest.fn() + + const report = fn( + 'in:inbox is:unread', + { maxThreads: 1, dryRun: true }, + { canonicalDomains: ['05_Tech_Infrastructure'] }, + { + GmailApp: mockGmail, + classifyFn: mockClassify, + } + ) + + expect(report.scanned).toBe(1) + expect(report.items[0].newDomain).toBe('05_Tech_Infrastructure') + expect(report.items[0].newSubLabel).toBe('Tech/Security') + expect(mockClassify).not.toHaveBeenCalled() + }) + + test('sweepAllUnreadInboxEmails queries in:inbox is:unread -is:starred -from:me', () => { + const mockGmail = { + search: jest.fn(() => []), + } + const report = sweepFn( + { maxThreads: 20 }, + { canonicalDomains: ['04_Family_Health'] }, + { GmailApp: mockGmail, classifyFn: jest.fn() } + ) + expect(mockGmail.search).toHaveBeenCalledWith( + 'in:inbox is:unread -is:starred -from:me', + 0, + 20 + ) + expect(report.dryRun).toBe(false) + }) + }) + }) + + describe('deterministicRuleClassify_ Scarsella curriculum handling', () => { + const implementations = [ + { + name: 'src/index.js', + fn: require('../src/index').deterministicRuleClassify_, + }, + { + name: 'code.gs', + fn: require('../code.gs').deterministicRuleClassify_, + }, + ] + + implementations.forEach(({ name, fn }) => { + describe(name, () => { + test('classifies generic Scarsella promo as Promotions archive', () => { + const res = fn( + 'info@scarsellakarate.com', + 'Special Summer Camp Discount!', + 'Sign up today and get 20% off uniforms' + ) + expect(res).not.toBeNull() + expect(res.category).toBe('Promotions') + expect(res.canonicalDomain).toBeNull() + }) + + test('bypasses marketing promo block when Scarsella email contains curriculum or testing', () => { + const resCurriculum = fn( + 'info@scarsellakarate.com', + 'March Belt Testing & Curriculum Schedule', + 'Attached is the testing schedule and curriculum form' + ) + expect( + resCurriculum === null || resCurriculum.canonicalDomain !== null + ).toBe(true) + if (resCurriculum) { + expect(resCurriculum.category).not.toBe('Promotions') + } + }) + + test('shields school 1:1 teacher correspondence (bcs-lions.org) into Primary', () => { + const res = fn( + 'asmith@bcs-lions.org', + 'Student - Math Progress & Tutoring Schedule', + 'Hello Parents, the student did great on the algebra quiz today...' + ) + expect(res).not.toBeNull() + expect(res.canonicalDomain).toBe('04_Family_Health') + expect(res.subLabel).toBe('Family/School-Student') + expect(res.category).toBe('Primary') + expect(res.action).toBe('keep') + expect(res.reasoning).toBe('Teacher 1:1 Shield') + }) + + test('shields school 1:1 teacher correspondence (briarwoodchristianschool.org) into Primary', () => { + const res = fn( + 'teacher@briarwoodchristianschool.org', + 'Student - Chemistry Study Guide & Quiz Update', + 'Hello Parents, here is the study guide for tomorrow...' + ) + expect(res).not.toBeNull() + expect(res.canonicalDomain).toBe('04_Family_Health') + expect(res.subLabel).toBe('Family/School-Student') + expect(res.category).toBe('Primary') + expect(res.action).toBe('keep') + expect(res.reasoning).toBe('Teacher 1:1 Shield') + }) + + test('classifies developer CI/CD monitoring alerts (SonarCloud, Sentry, Vercel) into Tech/Alerts', () => { + const sonarRes = fn( + 'notifications@sonarcloud.io', + 'Quality Gate passed on project', + 'Analysis report complete', + null, + {} + ) + expect(sonarRes).not.toBeNull() + expect(sonarRes.canonicalDomain).toBe('05_Tech_Infrastructure') + expect(sonarRes.subLabel).toBe('Tech/Alerts') + expect(sonarRes.category).toBe('Updates') + expect(sonarRes.action).toBe('archive') + + const vercelRes = fn( + 'notifications@vercel.com', + 'Deployment Succeeded', + 'Preview ready', + null, + {} + ) + expect(vercelRes).not.toBeNull() + expect(vercelRes.canonicalDomain).toBe('05_Tech_Infrastructure') + expect(vercelRes.subLabel).toBe('Tech/Alerts') + }) + + test('classifies utility bills (Alabama Power, BWWB) into Finance/Bills', () => { + const powerRes = fn( + 'ebill@alabamapower.com', + 'Your monthly electric bill is ready to view', + 'Account ending in 1234', + null, + {} + ) + expect(powerRes).not.toBeNull() + expect(powerRes.canonicalDomain).toBe('02_Finance_Legal') + expect(powerRes.subLabel).toBe('Finance/Bills') + expect(powerRes.category).toBe('Updates') + expect(powerRes.action).toBe('archive') + + const waterRes = fn( + 'ebilling@bwwb.org', + 'Water service statement available', + 'Your current statement', + null, + {} + ) + expect(waterRes).not.toBeNull() + expect(waterRes.canonicalDomain).toBe('02_Finance_Legal') + expect(waterRes.subLabel).toBe('Finance/Bills') + }) + + test('classifies carrier parcel shipments (UPS, FedEx, USPS) into Finance/Purchases', () => { + const upsRes = fn( + 'pkginfo@ups.com', + 'UPS Update: Package Scheduled Delivery', + 'Tracking number 1Z9999999999999999', + null, + {} + ) + expect(upsRes).not.toBeNull() + expect(upsRes.canonicalDomain).toBe('02_Finance_Legal') + expect(upsRes.subLabel).toBe('Finance/Purchases') + expect(upsRes.category).toBe('Updates') + expect(upsRes.action).toBe('archive') + expect(upsRes.reasoning).toBe('Carrier Tracking') + + const fedexRes = fn( + 'TrackingUpdates@fedex.com', + 'FedEx Shipment Delivered', + 'Your shipment was delivered to your front door', + null, + {} + ) + expect(fedexRes).not.toBeNull() + expect(fedexRes.canonicalDomain).toBe('02_Finance_Legal') + expect(fedexRes.subLabel).toBe('Finance/Purchases') + + const uspsRes = fn( + 'auto-reply@usps.com', + 'USPS Tracking: Package Out for Delivery', + 'Your item is out for delivery today', + null, + {} + ) + expect(uspsRes).not.toBeNull() + expect(uspsRes.canonicalDomain).toBe('02_Finance_Legal') + expect(uspsRes.subLabel).toBe('Finance/Purchases') + }) + + test('classifies extended banking statements and payment confirmations (Citi, Amex, APCO CU, Rocket Mortgage)', () => { + const citiRes = fn( + 'citicards@info.citi.com', + 'Your Costco Anywhere Visa statement is available', + 'Your monthly statement is ready to view', + null, + {} + ) + expect(citiRes).not.toBeNull() + expect(citiRes.canonicalDomain).toBe('02_Finance_Legal') + expect(citiRes.subLabel).toBe('Finance/Bills') + expect(citiRes.category).toBe('Updates') + expect(citiRes.action).toBe('archive') + + const amexRes = fn( + 'AmericanExpress@welcome.americanexpress.com', + 'Payment Confirmation', + 'Your payment scheduled for today was received', + null, + {} + ) + expect(amexRes).not.toBeNull() + expect(amexRes.canonicalDomain).toBe('02_Finance_Legal') + expect(amexRes.subLabel).toBe('Finance/Bills') + + const apcoRes = fn( + 'noreplies@apcocu.org', + 'Credit Union e-Statement Notification', + 'Your e-statement is ready', + null, + {} + ) + expect(apcoRes).not.toBeNull() + expect(apcoRes.canonicalDomain).toBe('02_Finance_Legal') + expect(apcoRes.subLabel).toBe('Finance/Bills') + + const mortgageRes = fn( + 'RocketMortgage@tc.rocketmortgage.com', + 'Monthly Mortgage Statement Available', + 'Your statement is ready', + null, + {} + ) + expect(mortgageRes).not.toBeNull() + expect(mortgageRes.canonicalDomain).toBe('02_Finance_Legal') + expect(mortgageRes.subLabel).toBe('Finance/Bills') + }) + + test('classifies payment receipts and retail orders (PayPal, Home Depot) into Finance/Purchases', () => { + const paypalRes = fn( + 'service@paypal.com', + 'Receipt for your PayPal payment', + 'You sent a payment of $25.00 USD', + null, + {} + ) + expect(paypalRes).not.toBeNull() + expect(paypalRes.canonicalDomain).toBe('02_Finance_Legal') + expect(paypalRes.subLabel).toBe('Finance/Purchases') + expect(paypalRes.category).toBe('Updates') + expect(paypalRes.action).toBe('archive') + expect(paypalRes.reasoning).toBe('Payment Receipt') + + const hdRes = fn( + 'HomeDepotPro@order.homedepot.com', + 'Your Home Depot Order Confirmation', + 'Your order has been received and is being processed', + null, + {} + ) + expect(hdRes).not.toBeNull() + expect(hdRes.canonicalDomain).toBe('02_Finance_Legal') + expect(hdRes.subLabel).toBe('Finance/Purchases') + expect(hdRes.category).toBe('Updates') + expect(hdRes.action).toBe('archive') + expect(hdRes.reasoning).toBe('Retail Order / Receipt') + }) + + test('classifies Vera/MiOS alerts into Household/Property and Apps Script alerts into Tech/Alerts', () => { + const veraRes = fn( + 'noreply3@notify.mios.com', + 'Vera Home Automation: Garage Door Opened', + 'Sensor triggered at front garage', + null, + {} + ) + expect(veraRes).not.toBeNull() + expect(veraRes.canonicalDomain).toBe('01_Household') + expect(veraRes.subLabel).toBe('Household/Property') + expect(veraRes.category).toBe('Updates') + expect(veraRes.action).toBe('archive') + + const appsScriptRes = fn( + 'noreply-apps-scripts-notifications@google.com', + 'Apps Script: Authorization Notification', + 'Summary of failures for Google Apps Script', + null, + {} + ) + expect(appsScriptRes).not.toBeNull() + expect(appsScriptRes.canonicalDomain).toBe('05_Tech_Infrastructure') + expect(appsScriptRes.subLabel).toBe('Tech/Alerts') + expect(appsScriptRes.category).toBe('Updates') + expect(appsScriptRes.action).toBe('archive') + }) + + test('classifies credit score notifications (CreditWise, Experian) into Finance/Banking archive', () => { + const capOneScore = fn( + 'capitalone@notification.capitalone.com', + 'Hi Alex, your score went up', + 'Your credit score increased on CreditWise.', + null, + {} + ) + expect(capOneScore).not.toBeNull() + expect(capOneScore.canonicalDomain).toBe('02_Finance_Legal') + expect(capOneScore.subLabel).toBe('Finance/Banking') + expect(capOneScore.category).toBe('Updates') + expect(capOneScore.action).toBe('archive') + expect(capOneScore.reasoning).toBe('Credit Score Alert') + + const experianScore = fn( + 'creditmonitoring@experian.com', + 'Your Experian Credit Report and Score Changed', + 'A new credit alert has been detected.', + null, + {} + ) + expect(experianScore).not.toBeNull() + expect(experianScore.canonicalDomain).toBe('02_Finance_Legal') + expect(experianScore.subLabel).toBe('Finance/Banking') + expect(experianScore.category).toBe('Updates') + expect(experianScore.action).toBe('archive') + }) + + test('classifies smart reorder and consumable replenishments into Household/Supplies archive', () => { + const inkAlert = fn( + 'no-reply@amazon.com', + "It's time to order the Canon CLI-281 Yellow Toner for your Canon TS6300 series-AirPrint", + 'Yellow toner level is at 0%', + null, + {} + ) + expect(inkAlert).not.toBeNull() + expect(inkAlert.canonicalDomain).toBe('01_Household') + expect(inkAlert.subLabel).toBe('Household/Supplies') + expect(inkAlert.category).toBe('Updates') + expect(inkAlert.action).toBe('archive') + expect(inkAlert.reasoning).toBe('Smart Reorder') + + const hpReplenish = fn( + 'instantink@hp.com', + 'Low ink alert: Replacement cartridges shipped automatically', + 'Smart reorder auto-replenishment status', + null, + {} + ) + expect(hpReplenish).not.toBeNull() + expect(hpReplenish.canonicalDomain).toBe('01_Household') + expect(hpReplenish.subLabel).toBe('Household/Supplies') + expect(hpReplenish.category).toBe('Updates') + expect(hpReplenish.action).toBe('archive') + }) + + test('classifies BrightArrow broadcasts and school announcements into Family/School-Student archive', () => { + const brightArrow = fn( + 'Notification@target10112.brightarrow.com', + 'Oakridge Community School for (Student, Alex)', + 'PowerSchool semester grades glitch is resolved', + null, + {} + ) + expect(brightArrow).not.toBeNull() + expect(brightArrow.canonicalDomain).toBe('04_Family_Health') + expect(brightArrow.subLabel).toBe('Family/School-Student') + expect(brightArrow.category).toBe('Updates') + expect(brightArrow.action).toBe('archive') + expect(brightArrow.reasoning).toBe('School Broadcast') + + const elearningDay = fn( + 'announcements@bcsk12.org', + 'E Learning Day Tomorrow: Weather Announcement', + 'Campus closed for e-learning day tomorrow', + null, + {} + ) + expect(elearningDay).not.toBeNull() + expect(elearningDay.canonicalDomain).toBe('04_Family_Health') + expect(elearningDay.subLabel).toBe('Family/School-Student') + expect(elearningDay.category).toBe('Updates') + expect(elearningDay.action).toBe('archive') + }) + + test('classifies general retail promo and marketing blasts with promotional subjects into Promotions archive', () => { + const promoRes = fn( + 'deals@store.example.com', + 'Flash Sale: 40% Off Limited Time Offer!', + 'Shop now and save on all clearance items', + null, + {} + ) + expect(promoRes).not.toBeNull() + expect(promoRes.canonicalDomain).toBeNull() + expect(promoRes.category).toBe('Promotions') + expect(promoRes.action).toBe('archive') + expect(promoRes.reasoning).toBe('Retail Promo') + + const receiptRes = fn( + 'store@example.com', + 'Order Receipt #12345 for your purchase', + 'Thank you for your order', + null, + {} + ) + if (receiptRes) { + expect(receiptRes.category).not.toBe('Promotions') + } + }) + + test('classifies "Events in your apiaries" and apiary telemetry into Projects/Telemetry archive', () => { + const apiaryEvents = fn( + 'alerts@broodminder.com', + 'Events in your apiaries', + 'An automated alert notification regarding device issues and status updates for hives in your apiaries.', + null, + {} + ) + expect(apiaryEvents).not.toBeNull() + expect(apiaryEvents.canonicalDomain).toBe('07_Community_NonProfit') + expect(apiaryEvents.subLabel).toBe('Projects/Telemetry') + expect(apiaryEvents.category).toBe('Updates') + expect(apiaryEvents.action).toBe('archive') + expect(apiaryEvents.reasoning).toBe('BroodMinder Telemetry') + + const myBroodminderAlert = fn( + 'notifications@mybroodminder.com', + 'Apiary Alert: Scale weight threshold exceeded', + 'Battery and weight report for yard', + null, + {} + ) + expect(myBroodminderAlert).not.toBeNull() + expect(myBroodminderAlert.canonicalDomain).toBe( + '07_Community_NonProfit' + ) + expect(myBroodminderAlert.subLabel).toBe('Projects/Telemetry') + expect(myBroodminderAlert.category).toBe('Updates') + expect(myBroodminderAlert.action).toBe('archive') + }) + + test('classifies bank APY and interest rate increase notices into Finance/Banking archive', () => { + const apyRes = fn( + 'EPC@email.empower.com', + 'Your Cash account now earns a higher APY', + 'Your account rate has increased to 3.70% APY automatically.', + null, + {} + ) + expect(apyRes).not.toBeNull() + expect(apyRes.canonicalDomain).toBe('02_Finance_Legal') + expect(apyRes.subLabel).toBe('Finance/Banking') + expect(apyRes.category).toBe('Updates') + expect(apyRes.action).toBe('archive') + expect(apyRes.reasoning).toBe('Bank Rate Update') + }) + + test('classifies school travel solicitations (EF Tours) into Family/School-Student Promotions archive', () => { + const efRes = fn( + 'consultant@ef.com', + '2028 Trip to South Korea', + 'Informational interest meeting announced for educational tour with early enrollment discount.', + null, + {} + ) + expect(efRes).not.toBeNull() + expect(efRes.canonicalDomain).toBe('04_Family_Health') + expect(efRes.subLabel).toBe('Family/School-Student') + expect(efRes.category).toBe('Promotions') + expect(efRes.action).toBe('archive') + expect(efRes.reasoning).toBe('School Travel Solicitation') + }) + + test('classifies SaaS trial expirations and cloud account plan notices into Tech/Alerts archive', () => { + const awsPlanRes = fn( + 'billing-subscriptions@aws.com', + 'Your AWS account was upgraded to a paid plan', + 'AWS account has been upgraded from the Free Tier to a paid subscription.', + null, + {} + ) + expect(awsPlanRes).not.toBeNull() + expect(awsPlanRes.canonicalDomain).toBe('05_Tech_Infrastructure') + expect(awsPlanRes.subLabel).toBe('Tech/Alerts') + expect(awsPlanRes.category).toBe('Updates') + expect(awsPlanRes.action).toBe('archive') + expect(awsPlanRes.reasoning).toBe('SaaS / Cloud Plan Alert') + + const trialEndingRes = fn( + 'notifications@example-saas.com', + 'Your free trial is ending in 3 days', + 'Your 14-day trial will expire soon. Upgrade your plan to keep access.', + null, + {} + ) + expect(trialEndingRes).not.toBeNull() + expect(trialEndingRes.canonicalDomain).toBe('05_Tech_Infrastructure') + expect(trialEndingRes.subLabel).toBe('Tech/Alerts') + expect(trialEndingRes.category).toBe('Updates') + expect(trialEndingRes.action).toBe('archive') + expect(trialEndingRes.reasoning).toBe('SaaS / Cloud Plan Alert') + }) + + test('shields third-party app authorization and Google account data sharing notices into Primary keep', () => { + const authNotice = fn( + 'noreply-accounts@google.com', + 'You shared some Google Account data with example-app.com', + 'Google confirmed an authorization event where Sign in with Google was used to log into example-app.', + null, + {} + ) + expect(authNotice).not.toBeNull() + expect(authNotice.canonicalDomain).toBe('05_Tech_Infrastructure') + expect(authNotice.subLabel).toBe('Tech/Security') + expect(authNotice.category).toBe('Primary') + expect(authNotice.action).toBe('keep') + expect(authNotice.reasoning).toBe('Third-Party Authorization Shield') + }) + + test('classifies package registry and PyPI trusted publisher alerts into Tech/Alerts archive', () => { + const pypiRes = fn( + 'noreply@pypi.org', + '[PyPI] Trusted publisher removed from example-package', + 'PyPI reported that user example removed a GitHub Actions trusted publisher from the project.', + null, + {} + ) + expect(pypiRes).not.toBeNull() + expect(pypiRes.canonicalDomain).toBe('05_Tech_Infrastructure') + expect(pypiRes.subLabel).toBe('Tech/Alerts') + expect(pypiRes.category).toBe('Updates') + expect(pypiRes.action).toBe('archive') + expect(pypiRes.reasoning).toBe('Package Registry Alert') + }) + + test('classifies hardware and IoT telemetry (QNAP NAS, UniFi, Home Assistant) into Tech/Alerts archive', () => { + const qnapRes = fn( + 'NotificationCenter@qnap.com', + '[QNAP NAS] RAID volume scrubbing finished', + 'Storage pool volume check completed with zero errors.', + null, + {} + ) + expect(qnapRes).not.toBeNull() + expect(qnapRes.canonicalDomain).toBe('05_Tech_Infrastructure') + expect(qnapRes.subLabel).toBe('Tech/Alerts') + expect(qnapRes.category).toBe('Updates') + expect(qnapRes.action).toBe('archive') + expect(qnapRes.reasoning).toBe('QNAP Telemetry') + + const unifiRes = fn( + 'notifications@ui.com', + 'UniFi Network: Gateway connection restored', + 'Your network gateway device reconnected.', + null, + {} + ) + expect(unifiRes).not.toBeNull() + expect(unifiRes.canonicalDomain).toBe('05_Tech_Infrastructure') + expect(unifiRes.subLabel).toBe('Tech/Alerts') + expect(unifiRes.category).toBe('Updates') + expect(unifiRes.action).toBe('archive') + expect(unifiRes.reasoning).toBe('UniFi Telemetry') + + const haRes = fn( + 'no-reply@nabucasa.com', + 'Your Home Assistant Cloud subscription will renew soon', + 'Your cloud connection subscription is set to renew automatically.', + null, + {} + ) + expect(haRes).not.toBeNull() + expect(haRes.canonicalDomain).toBe('05_Tech_Infrastructure') + expect(haRes.subLabel).toBe('Tech/Alerts') + expect(haRes.category).toBe('Updates') + expect(haRes.action).toBe('archive') + expect(haRes.reasoning).toBe('Home Assistant Telemetry') + }) + + test('classifies rideshare and travel receipts (Waymo) into Household/Travel archive', () => { + const waymoRes = fn( + 'noreply@waymo.com', + 'Thanks for riding with Waymo', + 'Here is your receipt for your completed Waymo ride.', + null, + {} + ) + expect(waymoRes).not.toBeNull() + expect(waymoRes.canonicalDomain).toBe('01_Household') + expect(waymoRes.subLabel).toBe('Household/Travel') + expect(waymoRes.category).toBe('Updates') + expect(waymoRes.action).toBe('archive') + expect(waymoRes.reasoning).toBe('Travel Confirmation') + }) + + test('classifies vehicle fuel e-receipts (Shell) into Vehicles/Maintenance archive', () => { + const shellRes = fn( + 'donotreply@mail.ereceiptshell.com', + 'Thanks for your Purchase! Your e-Receipt Inside!', + 'Electronic receipt for your recent fuel purchase transaction.', + null, + {} + ) + expect(shellRes).not.toBeNull() + expect(shellRes.canonicalDomain).toBe('03_Vehicles') + expect(shellRes.subLabel).toBe('Vehicles/Maintenance') + expect(shellRes.category).toBe('Updates') + expect(shellRes.action).toBe('archive') + expect(shellRes.reasoning).toBe('Vehicle Fuel/Maintenance Receipt') + }) + + test('classifies healthcare claim notices and EOBs into Finance/Insurance archive', () => { + const eobRes = fn( + 'ClaimNotifications@bcbsal.org', + 'Blue Cross and Blue Shield Explanation of Benefits', + 'One or more healthcare claims have been processed. View your online statement.', + null, + {} + ) + expect(eobRes).not.toBeNull() + expect(eobRes.canonicalDomain).toBe('02_Finance_Legal') + expect(eobRes.subLabel).toBe('Finance/Insurance') + expect(eobRes.category).toBe('Updates') + expect(eobRes.action).toBe('archive') + expect(eobRes.reasoning).toBe('Insurance Claim / EOB Notice') + }) + + test('classifies routine auto insurance billing into Finance/Bills while shielding claims in Primary', () => { + const autoPayRes = fn( + 'geico@et.geico.com', + 'Your Auto Pay payment will be processed soon', + 'Automatic monthly insurance premium payment scheduled.', + null, + {} + ) + expect(autoPayRes).not.toBeNull() + expect(autoPayRes.canonicalDomain).toBe('02_Finance_Legal') + expect(autoPayRes.subLabel).toBe('Finance/Bills') + expect(autoPayRes.category).toBe('Updates') + expect(autoPayRes.action).toBe('archive') + expect(autoPayRes.reasoning).toBe('Auto Insurance Routine Billing') + + // Claims, adjuster, or dispute communications must NOT match routine billing rule + const claimNotice = fn( + 'adjuster@geico.com', + 'Update on Claim #99887766 - Settlement Offer', + 'The insurance claim adjuster has completed inspection and prepared settlement details.', + null, + {} + ) + expect(claimNotice).toBeNull() + }) + + test('classifies commercial terms of service and privacy policy updates into non-canonical Updates archive', () => { + const tosRes = fn( + 'no-reply@platform.example.com', + 'Updates to our Terms of Service and Privacy Policy', + 'We are updating our user agreement and arbitration terms.', + null, + {} + ) + expect(tosRes).not.toBeNull() + expect(tosRes.canonicalDomain).toBeNull() + expect(tosRes.category).toBe('Updates') + expect(tosRes.action).toBe('archive') + expect(tosRes.reasoning).toBe('Policy / Terms Update') + }) + + test('classifies developer platform notifications (Google AI Studio, CodeAnt AI, Calendar) appropriately', () => { + const aiStudioRes = fn( + 'googleaistudio-noreply@google.com', + 'Gemini Developer API Quota Update', + 'Important changes to rate limits and API model quotas.', + null, + {} + ) + expect(aiStudioRes).not.toBeNull() + expect(aiStudioRes.canonicalDomain).toBe('05_Tech_Infrastructure') + expect(aiStudioRes.subLabel).toBe('Tech/Alerts') + expect(aiStudioRes.category).toBe('Updates') + expect(aiStudioRes.action).toBe('archive') + + const codeAntRes = fn( + 'features@updates.codeant.ai', + 'New AI Code Review Feature Available', + 'Try out automated code reviews with 30% discount this month.', + null, + {} + ) + expect(codeAntRes).not.toBeNull() + expect(codeAntRes.canonicalDomain).toBeNull() + expect(codeAntRes.category).toBe('Promotions') + expect(codeAntRes.action).toBe('archive') + + const calRes = fn( + 'calendar-notification@google.com', + 'Notification: Project Review @ Tue Oct 27', + 'Upcoming calendar event reminder.', + null, + {} + ) + expect(calRes).not.toBeNull() + expect(calRes.canonicalDomain).toBe('01_Household') + expect(calRes.subLabel).toBe('Household/Property') + expect(calRes.category).toBe('Updates') + expect(calRes.action).toBe('archive') + }) + + test('classifies non-profit charity impact newsletters (Ignite Hope) into Community_NonProfit Updates archive', () => { + const charityRes = fn( + 'info@ignite-hope.org', + '632 hours later, the doors opened!', + 'Ignite Hope shared a project milestone and renovation update sent via their mass mailer.', + null, + {} + ) + expect(charityRes).not.toBeNull() + expect(charityRes.canonicalDomain).toBe('07_Community_NonProfit') + expect(charityRes.subLabel).toBe('NonProfit/General') + expect(charityRes.category).toBe('Updates') + expect(charityRes.action).toBe('archive') + expect(charityRes.reasoning).toBe('NonProfit Impact Newsletter') + }) + + test('classifies school-wide sports tryouts (tennis tryouts) as school broadcast into Family/School-Student archive', () => { + const tryoutRes = fn( + 'staff@bcsk12.org', + 'Junior High Tennis Tryouts Next Week!', + 'Tennis tryouts will be held next week for all interested students.', + null, + {} + ) + expect(tryoutRes).not.toBeNull() + expect(tryoutRes.canonicalDomain).toBe('04_Family_Health') + expect(tryoutRes.subLabel).toBe('Family/School-Student') + expect(tryoutRes.category).toBe('Updates') + expect(tryoutRes.action).toBe('archive') + expect(tryoutRes.reasoning).toBe('School Broadcast') + }) + + test('classifies Ironmen weekly Bible study broadcast and notes into Community_NonProfit Updates archive', () => { + const bibleStudyRes = fn( + 'ministry@example.com', + 'IRONMEN 6:30am THURSDAY Acts 24 Study Notes', + 'Weekly meeting details and study guide covering Acts 24 with schedule notes.', + null, + {} + ) + expect(bibleStudyRes).not.toBeNull() + expect(bibleStudyRes.canonicalDomain).toBe('07_Community_NonProfit') + expect(bibleStudyRes.subLabel).toBe('NonProfit/General') + expect(bibleStudyRes.category).toBe('Updates') + expect(bibleStudyRes.action).toBe('archive') + expect(bibleStudyRes.reasoning).toBe('NonProfit Impact Newsletter') + }) + + test('classifies healthcare insurance payments (BCBSAL) into Finance/Bills Updates archive', () => { + const bcbsRes = fn( + 'bluecrosspayments@bcbsal.org', + 'Blue Cross and Blue Shield of Alabama Payments', + 'Your monthly insurance payment has been successfully processed.', + null, + {} + ) + expect(bcbsRes).not.toBeNull() + expect(bcbsRes.canonicalDomain).toBe('02_Finance_Legal') + expect(bcbsRes.subLabel).toBe('Finance/Bills') + expect(bcbsRes.category).toBe('Updates') + expect(bcbsRes.action).toBe('archive') + expect(bcbsRes.reasoning).toBe('Insurance Payment Receipt') + }) + + test('shields thread containing user-sent message with plain object fallback', () => { + const mockThread = { + getMessages: () => [{ from: 'testuser@example.com' }], + } + const config = { userAccountEmail: 'testuser@example.com' } + const res = fn( + 'other@example.com', + 'Subject', + 'Snippet', + mockThread, + config + ) + expect(res).not.toBeNull() + expect(res.category).toBe('Primary') + expect(res.action).toBe('keep') + expect(res.reasoning).toBe('User-Sent Shield') + }) + + test('classifies authentication 2FA codes and security sign-in alerts into Tech/Security archive', () => { + const authRes = fn( + 'no-reply@auth.example.com', + 'Your verification code is 492019', + 'Use this one-time passcode to verify your identity', + null, + {} + ) + expect(authRes).not.toBeNull() + expect(authRes.canonicalDomain).toBe('05_Tech_Infrastructure') + expect(authRes.subLabel).toBe('Tech/Security') + expect(authRes.category).toBe('Updates') + expect(authRes.action).toBe('archive') + expect(authRes.reasoning).toBe('Authentication/Sign-in Ping') + + const deviceRes = fn( + 'security@service.example.com', + 'Security alert for your account: New sign-in from new device', + 'We noticed a new device signed in to your account', + null, + {} + ) + expect(deviceRes).not.toBeNull() + expect(deviceRes.canonicalDomain).toBe('05_Tech_Infrastructure') + expect(deviceRes.subLabel).toBe('Tech/Security') + expect(deviceRes.category).toBe('Updates') + expect(deviceRes.action).toBe('archive') + }) + + test('classifies healthcare appointment reminders and pharmacy alerts into Family/Health-Medical archive', () => { + const apptRes = fn( + 'donotreply@mychart.example.org', + 'Upcoming Appointment Reminder for Tomorrow', + 'Your upcoming appointment is confirmed for 2:00 PM', + null, + {} + ) + expect(apptRes).not.toBeNull() + expect(apptRes.canonicalDomain).toBe('04_Family_Health') + expect(apptRes.subLabel).toBe('Family/Health-Medical') + expect(apptRes.category).toBe('Updates') + expect(apptRes.action).toBe('archive') + expect(apptRes.reasoning).toBe('Healthcare/Pharmacy Alert') + + const rxRes = fn( + 'rx@cvs.com', + 'Your prescription is ready for pickup', + 'Prescription ready at pharmacy location', + null, + {} + ) + expect(rxRes).not.toBeNull() + expect(rxRes.canonicalDomain).toBe('04_Family_Health') + expect(rxRes.subLabel).toBe('Family/Health-Medical') + expect(rxRes.category).toBe('Updates') + expect(rxRes.action).toBe('archive') + }) + + test('classifies travel reservations, flight status, and rideshare receipts into Household/Travel archive', () => { + const flightRes = fn( + 'ticket@delta.com', + 'Flight Confirmation: Check in for your flight', + 'Flight status and boarding pass details', + null, + {} + ) + expect(flightRes).not.toBeNull() + expect(flightRes.canonicalDomain).toBe('01_Household') + expect(flightRes.subLabel).toBe('Household/Travel') + expect(flightRes.category).toBe('Updates') + expect(flightRes.action).toBe('archive') + expect(flightRes.reasoning).toBe('Travel Confirmation') + + const uberRes = fn( + 'receipts@uber.com', + 'Your trip with Uber receipt', + 'Thanks for riding with Uber', + null, + {} + ) + expect(uberRes).not.toBeNull() + expect(uberRes.canonicalDomain).toBe('01_Household') + expect(uberRes.subLabel).toBe('Household/Travel') + expect(uberRes.category).toBe('Updates') + expect(uberRes.action).toBe('archive') + }) + + test('classifies customer feedback surveys into Promotions archive', () => { + const surveyRes = fn( + 'feedback@service.example.com', + 'How was your experience? Take our quick survey', + 'Tell us what you think. Your opinion matters to us.', + null, + {} + ) + expect(surveyRes).not.toBeNull() + expect(surveyRes.canonicalDomain).toBeNull() + expect(surveyRes.category).toBe('Promotions') + expect(surveyRes.action).toBe('archive') + expect(surveyRes.reasoning).toBe('Customer Survey') + }) + + test('classifies retail store receipts into Finance/Purchases and retail promos into Promotions', () => { + const targetReceipt = fn( + 'orders@target.com', + 'Your Target order #987654 is ready for pickup', + 'Your items are ready at customer service', + null, + {} + ) + expect(targetReceipt).not.toBeNull() + expect(targetReceipt.canonicalDomain).toBe('02_Finance_Legal') + expect(targetReceipt.subLabel).toBe('Finance/Purchases') + expect(targetReceipt.category).toBe('Updates') + expect(targetReceipt.action).toBe('archive') + expect(targetReceipt.reasoning).toBe('Retail Order / Receipt') + + const targetPromo = fn( + 'promotions@target.com', + 'Weekly Ad: Save up to 30% on home essentials', + 'Limited time offer on top brands', + null, + {} + ) + expect(targetPromo).not.toBeNull() + expect(targetPromo.canonicalDomain).toBeNull() + expect(targetPromo.category).toBe('Promotions') + expect(targetPromo.action).toBe('archive') + expect(targetPromo.reasoning).toBe('Retail Promo') + }) + + test('classifies social media alerts (LinkedIn, Pinterest, YouTube) into Social archive', () => { + const linkedinRes = fn( + 'messages-noreply@linkedin.com', + 'You have 3 new notifications on LinkedIn', + 'See what your connections are sharing', + null, + {} + ) + expect(linkedinRes).not.toBeNull() + expect(linkedinRes.canonicalDomain).toBeNull() + expect(linkedinRes.category).toBe('Social') + expect(linkedinRes.action).toBe('archive') + expect(linkedinRes.reasoning).toBe('News/Newsletter/Social') + + const pinterestRes = fn( + 'updates@pinterest.com', + 'Trending ideas for your home board', + 'Discover pins you might like', + null, + {} + ) + expect(pinterestRes).not.toBeNull() + expect(pinterestRes.canonicalDomain).toBeNull() + expect(pinterestRes.category).toBe('Social') + expect(pinterestRes.action).toBe('archive') + }) + }) + }) + }) + + describe('processEmailsWithAiClassifier execution & concurrency guard', () => { + const { + processEmailsWithAiClassifier: processEmailsCodeGs, + } = require('../code.gs') + const { + processEmailsWithAiClassifier: processEmailsIndex, + } = require('../src/index') + + const implementations = [ + { name: 'code.gs', fn: processEmailsCodeGs }, + { name: 'src/index.js', fn: processEmailsIndex }, + ] + + implementations.forEach(({ name, fn }) => { + describe(name, () => { + const config = { + geminiApiKey: 'test-key', + unprocessedQuery: 'in:inbox -label:Processed', + processedLabel: 'Processed', + userAccountEmail: 'test@example.com', + } + + test('skips processing if LockService cannot acquire script lock within 10s', () => { + expect(typeof fn).toBe('function') + const mockLock = { + tryLock: jest.fn(() => false), + releaseLock: jest.fn(), + } + const mockLockService = { + getScriptLock: jest.fn(() => mockLock), + } + const mockGmail = { + search: jest.fn(), + } + + fn(config, { + LockService: mockLockService, + GmailApp: mockGmail, + }) + + expect(mockLockService.getScriptLock).toHaveBeenCalled() + expect(mockLock.tryLock).toHaveBeenCalledWith(10000) + expect(mockGmail.search).not.toHaveBeenCalled() + expect(mockLock.releaseLock).not.toHaveBeenCalled() + }) + + test('acquires lock, processes threads, and releases lock in finally block', () => { + expect(typeof fn).toBe('function') + const mockLock = { + tryLock: jest.fn(() => true), + releaseLock: jest.fn(), + } + const mockLockService = { + getScriptLock: jest.fn(() => mockLock), + } + const mockGmail = { + search: jest.fn(() => []), + } + + fn(config, { + LockService: mockLockService, + GmailApp: mockGmail, + }) + + expect(mockLockService.getScriptLock).toHaveBeenCalled() + expect(mockLock.tryLock).toHaveBeenCalledWith(10000) + expect(mockGmail.search).toHaveBeenCalled() + expect(mockLock.releaseLock).toHaveBeenCalled() + }) + + test('releases lock in finally block even if search throws', () => { + expect(typeof fn).toBe('function') + const mockLock = { + tryLock: jest.fn(() => true), + releaseLock: jest.fn(), + } + const mockLockService = { + getScriptLock: jest.fn(() => mockLock), + } + const mockGmail = { + search: jest.fn(() => { + throw new Error('API failure') + }), + } + + expect(() => { + fn(config, { + LockService: mockLockService, + GmailApp: mockGmail, + }) + }).toThrow('API failure') + + expect(mockLock.releaseLock).toHaveBeenCalled() + }) + + test('secondary sweep query targets unread unstarred inbox threads for realignment', () => { + expect(typeof fn).toBe('function') + const mockLock = { + tryLock: jest.fn(() => true), + releaseLock: jest.fn(), + } + const mockLockService = { + getScriptLock: jest.fn(() => mockLock), + } + const mockGmail = { + search: jest.fn(() => []), + } + + fn(config, { + LockService: mockLockService, + GmailApp: mockGmail, + }) + + expect(mockGmail.search).toHaveBeenCalledTimes(2) + expect(mockGmail.search.mock.calls[0][0]).toBe( + 'in:inbox -label:Processed' + ) + const sweepQuery = mockGmail.search.mock.calls[1][0] + expect(sweepQuery).toBe( + 'in:inbox is:unread -is:starred -from:me -label:AI/Manual-Review' + ) + }) + + test('gracefully skips empty threads without throwing TypeError', () => { + const emptyThread = { + getId: () => 'th-empty', + getMessages: () => [], + } + const mockLock = { + tryLock: jest.fn(() => true), + releaseLock: jest.fn(), + } + const mockLockService = { + getScriptLock: jest.fn(() => mockLock), + } + const mockGmail = { + search: jest.fn(() => [emptyThread]), + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + } + + expect(() => { + fn(config, { + LockService: mockLockService, + GmailApp: mockGmail, + }) + }).not.toThrow() + }) + + test('skips inter-thread rate-limit sleep when thread is classified deterministically', () => { + const mockLock = { + tryLock: jest.fn(() => true), + releaseLock: jest.fn(), + } + const mockLockService = { + getScriptLock: jest.fn(() => mockLock), + } + const mockThread = { + getId: () => 'th-det-1', + getMessages: () => [ + { + getFrom: () => 'alerts@broodminder.com', + getSubject: () => 'BroodMinder Apiary Telemetry Alert', + getPlainBody: () => 'Hive temperature reading normal.', + getDate: () => new Date(), + }, + ], + getLabels: () => [], + addLabel: jest.fn(), + moveToArchive: jest.fn(), + moveToTrash: jest.fn(), + markRead: jest.fn(), + } + const mockGmail = { + search: jest.fn(() => [mockThread]), + getUserLabelByName: jest.fn(() => ({ getName: () => 'Processed' })), + } + const mockSleep = jest.fn() + + fn(config, { + LockService: mockLockService, + GmailApp: mockGmail, + Utilities: { sleep: mockSleep }, + }) + + expect(mockSleep).not.toHaveBeenCalled() + expect(mockThread.addLabel).toHaveBeenCalled() + }) + + test('invokes inter-thread rate-limit sleep when remote API classification is performed', () => { + const mockLock = { + tryLock: jest.fn(() => true), + releaseLock: jest.fn(), + } + const mockLockService = { + getScriptLock: jest.fn(() => mockLock), + } + const mockThread = { + getId: () => 'th-remote-1', + getMessages: () => [ + { + getFrom: () => 'random-contact@example.com', + getSubject: () => 'Custom Inquiry', + getPlainBody: () => 'Unrecognized email body text.', + getDate: () => new Date(), + }, + ], + getLabels: () => [], + addLabel: jest.fn(), + moveToArchive: jest.fn(), + moveToTrash: jest.fn(), + markRead: jest.fn(), + } + const mockGmail = { + search: jest.fn(() => [mockThread]), + getUserLabelByName: jest.fn(() => ({ getName: () => 'Processed' })), + } + const mockSleep = jest.fn() + const mockClassifyFn = jest.fn(() => ({ + canonicalDomain: '01_Household', + subLabel: 'Household/Property', + category: 'Updates', + action: 'keep', + confidence: 0.95, + })) + + fn(config, { + LockService: mockLockService, + GmailApp: mockGmail, + Utilities: { sleep: mockSleep }, + classifyFn: mockClassifyFn, + }) + + expect(mockClassifyFn).toHaveBeenCalled() + expect(mockSleep).toHaveBeenCalledWith(2000) + }) + + test('treats GitHub sync failures as non-blocking and preserves thread labeling', () => { + const mockMessage = { + getFrom: () => 'sender@example.com', + getSubject: () => 'Invoice Document', + getPlainBody: () => 'Please find attached invoice.', + getDate: () => new Date(), + getAttachments: () => [], + } + const addedLabels = [] + const mockThread = { + getId: () => 'th-doc-1', + getMessages: () => [mockMessage], + addLabel: jest.fn((l) => + addedLabels.push( + typeof l.getName === 'function' ? l.getName() : String(l) + ) + ), + getLabels: () => [], + markRead: jest.fn(), + moveToArchive: jest.fn(), + } + const mockLock = { + tryLock: jest.fn(() => true), + releaseLock: jest.fn(), + } + const mockLockService = { + getScriptLock: jest.fn(() => mockLock), + } + const mockGmail = { + search: jest.fn(() => [mockThread]), + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + } + + const ghFailingConfig = { + ...config, + githubToken: 'gh-mock-token', + } + + global.UrlFetchApp = { + fetch: jest.fn(() => { + throw new Error('GitHub network connection timeout') + }), + } + + expect(() => { + fn(ghFailingConfig, { + LockService: mockLockService, + GmailApp: mockGmail, + classifyFn: () => ({ + canonicalDomain: '02_Finance_Legal', + subLabel: 'Finance/Bills', + category: 'Updates', + action: 'archive', + confidence: 0.99, + title: 'Invoice', + summary: 'Invoice document', + }), + }) + }).not.toThrow() + + expect(addedLabels).toContain('Processed') + expect(mockLock.releaseLock).toHaveBeenCalled() + }) + + test('gracefully breaks thread processing loop when approaching 270s execution budget', () => { + const createThread = (id) => ({ + getId: () => id, + getMessages: () => [ + { + getFrom: () => 'sender@example.com', + getSubject: () => 'Sub ' + id, + getPlainBody: () => 'body ' + id, + getDate: () => new Date(), + getAttachments: () => [], + }, + ], + addLabel: jest.fn(), + getLabels: () => [], + markRead: jest.fn(), + moveToArchive: jest.fn(), + }) + + const threads = [ + createThread('t-1'), + createThread('t-2'), + createThread('t-3'), + ] + const mockLock = { + tryLock: jest.fn(() => true), + releaseLock: jest.fn(), + } + const mockLockService = { + getScriptLock: jest.fn(() => mockLock), + } + const mockGmail = { + search: jest.fn(() => threads), + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + } + + let callCount = 0 + const initialTime = 1000000 + jest.spyOn(Date, 'now').mockImplementation(() => { + callCount++ + if (callCount > 3) { + return initialTime + 275000 + } + return initialTime + }) + + try { + fn(config, { + LockService: mockLockService, + GmailApp: mockGmail, + classifyFn: () => ({ + canonicalDomain: '01_Household', + subLabel: 'Household/Property', + category: 'Updates', + action: 'archive', + confidence: 0.99, + title: 'Household Note', + summary: 'Household item', + }), + }) + + expect(threads[0].addLabel).toHaveBeenCalled() + expect(threads[2].addLabel).not.toHaveBeenCalled() + expect(mockLock.releaseLock).toHaveBeenCalled() + } finally { + Date.now.mockRestore() + } + }) + + test('disables remote API for remainder of batch after consecutive remote failures while allowing deterministic rules to succeed', () => { + const mockLock = { + tryLock: jest.fn(() => true), + releaseLock: jest.fn(), + } + const mockLockService = { + getScriptLock: jest.fn(() => mockLock), + } + + const threadRemote1 = { + getId: () => 'th-remote-1', + getMessages: () => [ + { + getFrom: () => 'unknown1@custom.com', + getSubject: () => 'Unrecognized Subject 1', + getPlainBody: () => 'Custom body 1', + getDate: () => new Date(), + }, + ], + addLabel: jest.fn(), + } + const threadRemote2 = { + getId: () => 'th-remote-2', + getMessages: () => [ + { + getFrom: () => 'unknown2@custom.com', + getSubject: () => 'Unrecognized Subject 2', + getPlainBody: () => 'Custom body 2', + getDate: () => new Date(), + }, + ], + addLabel: jest.fn(), + } + const threadDeterministic = { + getId: () => 'th-det-3', + getMessages: () => [ + { + getFrom: () => 'alerts@broodminder.com', + getSubject: () => 'BroodMinder Apiary Telemetry Alert', + getPlainBody: () => 'Hive temperature reading normal.', + getDate: () => new Date(), + }, + ], + addLabel: jest.fn(), + moveToArchive: jest.fn(), + } + const threadRemote3 = { + getId: () => 'th-remote-3', + getMessages: () => [ + { + getFrom: () => 'unknown3@custom.com', + getSubject: () => 'Unrecognized Subject 3', + getPlainBody: () => 'Custom body 3', + getDate: () => new Date(), + }, + ], + addLabel: jest.fn(), + } + + const mockGmail = { + search: jest.fn(() => [ + threadRemote1, + threadRemote2, + threadDeterministic, + threadRemote3, + ]), + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + } + const mockClassifyFn = jest.fn(() => null) + + fn(config, { + LockService: mockLockService, + GmailApp: mockGmail, + classifyFn: mockClassifyFn, + }) + + expect(mockClassifyFn).toHaveBeenCalledTimes(2) + expect(threadDeterministic.addLabel).toHaveBeenCalled() + expect(threadRemote3.addLabel).not.toHaveBeenCalled() + }) + }) + }) + + describe('getFileHash blob safety', () => { + const { getFileHash: getHashCodeGs } = require('../code.gs') + const { getFileHash: getHashIndexJs } = require('../src/index.js') + + test('code.gs getFileHash safely handles null and invalid blobs without throwing', () => { + expect(getHashCodeGs(null)).toBe('') + expect(getHashCodeGs(undefined)).toBe('') + expect(getHashCodeGs({})).toBe('') + }) + + test('src/index.js getFileHash safely handles null and invalid blobs without throwing', () => { + if (typeof getHashIndexJs === 'function') { + expect(getHashIndexJs(null)).toBe('') + expect(getHashIndexJs(undefined)).toBe('') + expect(getHashIndexJs({})).toBe('') + } + }) + }) + + describe('isThreadSentOrRepliedByUser user reply shield', () => { + const { + isThreadSentOrRepliedByUser: isSentCodeGs, + } = require('../code.gs') + const { + isThreadSentOrRepliedByUser: isSentIndexJs, + } = require('../src/index.js') + + const implementations = [ + { name: 'code.gs', fn: isSentCodeGs }, + { name: 'src/index.js', fn: isSentIndexJs }, + ] + + implementations.forEach(({ name, fn }) => { + describe(name, () => { + test('returns true when message sender contains user email case-insensitively', () => { + const thread = { + getMessages: () => [ + { getFrom: () => 'Partner <partner@example.com>' }, + { getFrom: () => 'Account Owner <DON@EXAMPLE.COM>' }, + ], + } + expect(fn(thread, 'don@example.com')).toBe(true) + }) + + test('returns false when no messages are from user email', () => { + const thread = { + getMessages: () => [ + { getFrom: () => 'Partner <partner@example.com>' }, + ], + } + expect(fn(thread, 'don@example.com')).toBe(false) + }) + + test('returns false safely without throwing when userEmail is empty or missing', () => { + const thread = { + getMessages: () => [{ getFrom: () => 'any@example.com' }], + } + expect(fn(thread, '')).toBe(false) + expect(fn(thread, null)).toBe(false) + expect(fn(thread, undefined)).toBe(false) + }) + + test('handles malformed messages with null getFrom safely', () => { + const thread = { + getMessages: () => [ + null, + { getFrom: () => null }, + { getFrom: () => undefined }, + ], + } + expect(() => { + expect(fn(thread, 'don@example.com')).toBe(false) + }).not.toThrow() + }) + }) + }) + }) + + describe('purgeExpiredEmailsByRetentionPolicy safety & error boundary', () => { + const { + purgeExpiredEmailsByRetentionPolicy: purgeCodeGs, + } = require('../code.gs') + const { + purgeExpiredEmailsByRetentionPolicy: purgeIndexJs, + } = require('../src/index.js') + + const implementations = [ + { name: 'code.gs', fn: purgeCodeGs }, + { name: 'src/index.js', fn: purgeIndexJs }, + ] + + implementations.forEach(({ name, fn }) => { + describe(name, () => { + test('moves non-exempt expired thread to trash while exempting core sub-label and user replies', () => { + const trashableThread = { + getId: () => 't-trash', + getFirstMessageSubject: () => 'Expired Promo 2024', + getLabels: () => [{ getName: () => 'Promotions' }], + getMessages: () => [{ getFrom: () => 'promo@store.example.com' }], + moveToTrash: jest.fn(), + } + + const coreSublabelThread = { + getId: () => 't-core-sublabel', + getFirstMessageSubject: () => 'Bank Notice 2024', + getLabels: () => [{ getName: () => 'Finance/Banking' }], + getMessages: () => [ + { getFrom: () => 'service@bank.example.com' }, + ], + moveToTrash: jest.fn(), + } + + const userRepliedThread = { + getId: () => 't-user-replied', + getFirstMessageSubject: () => 'Old Discussion 2024', + getLabels: () => [{ getName: () => 'Forums' }], + getMessages: () => [ + { getFrom: () => 'stranger@forum.example.com' }, + { getFrom: () => 'Account Owner <don@example.com>' }, + ], + moveToTrash: jest.fn(), + } + + const throwingThread = { + getId: () => 't-throw', + getFirstMessageSubject: () => 'Locked Thread', + getLabels: () => [{ getName: () => 'Social' }], + getMessages: () => [{ getFrom: () => 'social@app.example.com' }], + moveToTrash: jest.fn(() => { + throw new Error('Thread locked') + }), + } + + const followingThread = { + getId: () => 't-following', + getFirstMessageSubject: () => 'Expired Deal 2024', + getLabels: () => [{ getName: () => 'Promotions' }], + getMessages: () => [{ getFrom: () => 'deals@store.example.com' }], + moveToTrash: jest.fn(), + } + + const mockGmailApp = { + search: jest.fn(() => [ + trashableThread, + coreSublabelThread, + userRepliedThread, + throwingThread, + followingThread, + ]), + } + + const testConfig = { + userAccountEmail: 'don@example.com', + canonicalDomains: ['01_Household', '02_Finance_Legal'], + } + + const result = fn(testConfig, { GmailApp: mockGmailApp }) + + expect(trashableThread.moveToTrash).toHaveBeenCalled() + expect(coreSublabelThread.moveToTrash).not.toHaveBeenCalled() + expect(userRepliedThread.moveToTrash).not.toHaveBeenCalled() + expect(throwingThread.moveToTrash).toHaveBeenCalled() + expect(followingThread.moveToTrash).toHaveBeenCalled() + expect(result.trashedCount).toBe(2) + }) + }) + }) + }) + + describe('auditEmailClassifications daily audit sweep', () => { + const { auditEmailClassifications: auditCodeGs } = require('../code.gs') + const { + auditEmailClassifications: auditIndexJs, + } = require('../src/index.js') + + const implementations = [ + { name: 'code.gs', fn: auditCodeGs }, + { name: 'src/index.js', fn: auditIndexJs }, + ] + + implementations.forEach(({ name, fn }) => { + describe(name, () => { + test('scans processed threads, generates report, and dispatches email digest when anomalies found', () => { + const anomalousThread = { + getId: () => 't-anomaly', + getFirstMessageSubject: () => 'Huge Clearance Sale 70% Off', + getMessages: () => [{ getFrom: () => 'store@deals.example.com' }], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => '01_Household' }, + ], + } + const mockSendEmail = jest.fn() + const mockGmail = { + search: jest.fn(() => [anomalousThread]), + sendEmail: mockSendEmail, + } + const testConfig = { + processedLabel: 'Processed', + auditLookbackDays: 2, + auditDigestEmail: 'digest@example.com', + canonicalDomains: ['01_Household'], + } + + const report = fn(testConfig, { GmailApp: mockGmail }) + + expect(mockGmail.search).toHaveBeenCalledWith( + 'label:Processed newer_than:2d', + 0, + 50 + ) + expect(report.scannedCount).toBe(1) + expect(report.flaggedCount).toBe(1) + expect(mockSendEmail).toHaveBeenCalledWith( + 'digest@example.com', + expect.stringContaining( + 'Daily Classification Audit: 1 anomalies detected' + ), + expect.stringContaining('GMAIL AI CLASSIFICATION AUDIT REPORT') + ) + }) + + test('does not send email when all threads are compliant', () => { + const compliantThread = { + getId: () => 't-compliant', + getFirstMessageSubject: () => 'Quarterly Bank Statement', + getMessages: () => [{ getFrom: () => 'bank@example.com' }], + getLabels: () => [ + { getName: () => 'Processed' }, + { getName: () => 'Finance/Banking' }, + ], + } + const mockSendEmail = jest.fn() + const mockGmail = { + search: jest.fn(() => [compliantThread]), + sendEmail: mockSendEmail, + } + const testConfig = { + processedLabel: 'Processed', + auditLookbackDays: 2, + auditDigestEmail: 'digest@example.com', + canonicalDomains: ['02_Finance_Legal'], + } + + const report = fn(testConfig, { GmailApp: mockGmail }) + + expect(report.scannedCount).toBe(1) + expect(report.flaggedCount).toBe(0) + expect(mockSendEmail).not.toHaveBeenCalled() + }) + }) + + describe('listAvailableGeminiModels', () => { + const { + listAvailableGeminiModels: listCodeGs, + } = require('../code.gs') + const { + listAvailableGeminiModels: listIndexJs, + } = require('../src/index.js') + + const implementations = [ + { name: 'code.gs', fn: listCodeGs }, + { name: 'src/index.js', fn: listIndexJs }, + ] + + implementations.forEach(({ name, fn }) => { + describe(name, () => { + test('returns empty array when UrlFetchApp or config/API key is missing', () => { + expect(fn(null, null)).toEqual([]) + expect(fn({}, null)).toEqual([]) + expect(fn({ geminiApiKey: '' }, { UrlFetchApp: {} })).toEqual( + [] + ) + }) + + test('queries models API and returns list of model names', () => { + const mockFetch = jest.fn(() => ({ + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + models: [ + { name: 'models/gemini-2.0-flash' }, + { name: 'models/gemini-1.5-flash' }, + { name: 'models/gemini-1.5-pro' }, + ], + }), + })) + + const models = fn( + { geminiApiKey: 'test-api-key' }, + { UrlFetchApp: { fetch: mockFetch } } + ) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://generativelanguage.googleapis.com/v1beta/models', + { + headers: { 'x-goog-api-key': 'test-api-key' }, + muteHttpExceptions: true, + } + ) + expect(models).toEqual([ + 'models/gemini-2.0-flash', + 'models/gemini-1.5-flash', + 'models/gemini-1.5-pro', + ]) + }) + + test('handles non-200 responses and exceptions gracefully', () => { + const mockFetchError = jest.fn(() => ({ + getResponseCode: () => 403, + getContentText: () => JSON.stringify({ error: 'Forbidden' }), + })) + + expect( + fn( + { geminiApiKey: 'test-api-key' }, + { UrlFetchApp: { fetch: mockFetchError } } + ) + ).toEqual([]) + + const mockFetchThrow = jest.fn(() => { + throw new Error('Network error') + }) + + expect( + fn( + { geminiApiKey: 'test-api-key' }, + { UrlFetchApp: { fetch: mockFetchThrow } } + ) + ).toEqual([]) + }) + }) + }) + }) + }) + }) + }) + + describe('Durable Queuing & Cross-Execution Recovery Architecture', () => { + const implementations = [ + { + name: 'src/index.js', + mod: require('../src/index.js'), + }, + { + name: 'code.gs', + mod: require('../code.gs'), + }, + ] + + describe('deterministicRuleClassify_ for 06_Work_Career', () => { + implementations.forEach(({ name, mod }) => { + describe(name, () => { + test('classifies employment agreement into 06_Work_Career Primary keep', () => { + const res = mod.deterministicRuleClassify_( + 'hr@company.com', + 'Employment Agreement - Jane Doe', + 'Please find attached the signed employment agreement and restrictive covenants.' + ) + expect(res).not.toBeNull() + expect(res.canonicalDomain).toBe('06_Work_Career') + expect(res.subLabel).toBe('Work/Career') + expect(res.category).toBe('Primary') + expect(res.action).toBe('keep') + expect(res.confidence).toBe(0.99) + }) + + test('classifies offer letter from snippet into 06_Work_Career Primary keep', () => { + const res = mod.deterministicRuleClassify_( + 'recruiter@techcorp.com', + 'Next Steps in Process', + 'We are pleased to offer you the position of Principal Architect. Terms of employment are enclosed.' + ) + expect(res).not.toBeNull() + expect(res.canonicalDomain).toBe('06_Work_Career') + expect(res.subLabel).toBe('Work/Career') + expect(res.category).toBe('Primary') + expect(res.action).toBe('keep') + }) + + test('classifies interview confirmation or screen into 06_Work_Career', () => { + const res = mod.deterministicRuleClassify_( + 'talent@startup.io', + 'Interview Confirmation: Technical Phone Screen', + 'Here is the link for your upcoming interview with the engineering team.' + ) + expect(res).not.toBeNull() + expect(res.canonicalDomain).toBe('06_Work_Career') + expect(res.category).toBe('Primary') + }) + }) + }) + }) + + describe('Circuit Breaker Helpers', () => { + implementations.forEach(({ name, mod }) => { + describe(name, () => { + let mockStore + let mockProps + + beforeEach(() => { + mockStore = {} + mockProps = { + getProperty: jest.fn((k) => mockStore[k] || null), + setProperty: jest.fn((k, v) => { + mockStore[k] = String(v) + }), + deleteProperty: jest.fn((k) => { + delete mockStore[k] + }), + } + }) + + test('isCircuitBreakerOpen returns false when no property is set', () => { + expect(mod.isCircuitBreakerOpen(mockProps, 1000000)).toBe(false) + }) + + test('isCircuitBreakerOpen returns false when backoff time has passed', () => { + mockStore['GEMINI_BACKOFF_UNTIL'] = '1000500' + expect(mod.isCircuitBreakerOpen(mockProps, 1001000)).toBe(false) + }) + + test('isCircuitBreakerOpen returns true when backoff time is in the future', () => { + mockStore['GEMINI_BACKOFF_UNTIL'] = '2000000' + expect(mod.isCircuitBreakerOpen(mockProps, 1000000)).toBe(true) + }) + + test('recordCircuitBreakerFailure computes exponential backoff and sets properties', () => { + const now = 1000000 + const delay1 = mod.recordCircuitBreakerFailure(mockProps, null, now) + expect(delay1).toBe(60000) + expect(mockStore['GEMINI_BACKOFF_FAILURES']).toBe('1') + expect(mockStore['GEMINI_BACKOFF_UNTIL']).toBe(String(now + 60000)) + + const delay2 = mod.recordCircuitBreakerFailure(mockProps, null, now) + expect(delay2).toBe(120000) + expect(mockStore['GEMINI_BACKOFF_FAILURES']).toBe('2') + expect(mockStore['GEMINI_BACKOFF_UNTIL']).toBe(String(now + 120000)) + }) + + test('recordCircuitBreakerFailure honors retryDelayMs if greater than exponential backoff', () => { + const now = 1000000 + const delay = mod.recordCircuitBreakerFailure( + mockProps, + 180000, + now + ) + expect(delay).toBe(180000) + expect(mockStore['GEMINI_BACKOFF_UNTIL']).toBe(String(now + 180000)) + }) + + test('recordCircuitBreakerFailure caps backoff at 1 hour (3600000ms)', () => { + const now = 1000000 + mockStore['GEMINI_BACKOFF_FAILURES'] = '10' + const delay = mod.recordCircuitBreakerFailure( + mockProps, + 99999999, + now + ) + expect(delay).toBe(3600000) + }) + + test('recordCircuitBreakerSuccess clears backoff properties', () => { + mockStore['GEMINI_BACKOFF_UNTIL'] = '2000000' + mockStore['GEMINI_BACKOFF_FAILURES'] = '3' + mod.recordCircuitBreakerSuccess(mockProps) + expect(mockStore['GEMINI_BACKOFF_UNTIL']).toBeUndefined() + expect(mockStore['GEMINI_BACKOFF_FAILURES']).toBeUndefined() + }) + }) + }) + }) + + describe('Thread Retry Helpers', () => { + implementations.forEach(({ name, mod }) => { + describe(name, () => { + let cacheStore + let mockCache + const thread = { getId: () => 'th-retry-123' } + + beforeEach(() => { + cacheStore = {} + mockCache = { + get: jest.fn((k) => cacheStore[k] || null), + put: jest.fn((k, v) => { + cacheStore[k] = String(v) + }), + remove: jest.fn((k) => { + delete cacheStore[k] + }), + } + }) + + test('getThreadRetryCount_ returns 0 initially and increments correctly', () => { + expect(mod.getThreadRetryCount_(thread, mockCache)).toBe(0) + const c1 = mod.incrementThreadRetryCount_(thread, mockCache) + expect(c1).toBe(1) + expect(mod.getThreadRetryCount_(thread, mockCache)).toBe(1) + const c2 = mod.incrementThreadRetryCount_(thread, mockCache) + expect(c2).toBe(2) + }) + + test('clearThreadRetryCount_ removes thread retry key from cache', () => { + mod.incrementThreadRetryCount_(thread, mockCache) + expect(mod.getThreadRetryCount_(thread, mockCache)).toBe(1) + mod.clearThreadRetryCount_(thread, mockCache) + expect(mod.getThreadRetryCount_(thread, mockCache)).toBe(0) + }) + }) + }) + }) + + describe('processEmailsWithAiClassifier Durable Queue & Escalation', () => { + implementations.forEach(({ name, mod }) => { + describe(name, () => { + let mockLockService + let mockLock + let mockPropsStore + let mockProps + let mockCacheStore + let mockCache + + beforeEach(() => { + mockLock = { + tryLock: jest.fn(() => true), + releaseLock: jest.fn(), + } + mockLockService = { + getScriptLock: jest.fn(() => mockLock), + } + mockPropsStore = {} + mockProps = { + getProperty: jest.fn((k) => mockPropsStore[k] || null), + setProperty: jest.fn((k, v) => { + mockPropsStore[k] = String(v) + }), + deleteProperty: jest.fn((k) => { + delete mockPropsStore[k] + }), + } + mockCacheStore = {} + mockCache = { + get: jest.fn((k) => mockCacheStore[k] || null), + put: jest.fn((k, v) => { + mockCacheStore[k] = String(v) + }), + remove: jest.fn((k) => { + delete mockCacheStore[k] + }), + } + }) + + test('applies retryLabel (AI/Needs-Retry) when remote classification fails', () => { + const addedLabels = [] + const thread = { + getId: () => 'th-fail-1', + getMessages: () => [ + { + getFrom: () => 'obscure@sender.com', + getSubject: () => 'Obscure Subject Without Rule', + getPlainBody: () => 'Random content', + getDate: () => new Date(), + }, + ], + getLabels: () => [], + addLabel: jest.fn((lbl) => { + addedLabels.push(lbl.getName()) + }), + removeLabel: jest.fn(), + } + + const mockGmail = { + search: jest.fn(() => [thread]), + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + } + const mockClassifyFn = jest.fn(() => null) + + mod.processEmailsWithAiClassifier( + { + geminiApiKey: 'test-key', + processedLabel: 'Processed', + retryLabel: 'AI/Needs-Retry', + manualReviewLabel: 'AI/Manual-Review', + unprocessedQuery: 'in:inbox', + }, + { + LockService: mockLockService, + GmailApp: mockGmail, + classifyFn: mockClassifyFn, + PropertiesService: { getScriptProperties: () => mockProps }, + CacheService: { getScriptCache: () => mockCache }, + } + ) + + expect(mockClassifyFn).toHaveBeenCalledTimes(1) + expect(addedLabels).toContain('AI/Needs-Retry') + expect(addedLabels).not.toContain('Processed') + expect(mockPropsStore['GEMINI_BACKOFF_FAILURES']).toBe('1') + }) + + test('escalates to manualReviewLabel (AI/Manual-Review) and removes retryLabel after 5 failures', () => { + const addedLabels = [] + const removedLabels = [] + mockCacheStore['retry_count_th-poison-5'] = '4' + + const retryLabelObj = { getName: () => 'AI/Needs-Retry' } + const thread = { + getId: () => 'th-poison-5', + getMessages: () => [ + { + getFrom: () => 'poison@sender.com', + getSubject: () => 'Poison Pill Email', + getPlainBody: () => 'Malformatted garbage', + getDate: () => new Date(), + }, + ], + getLabels: () => [retryLabelObj], + addLabel: jest.fn((lbl) => { + addedLabels.push(lbl.getName()) + }), + removeLabel: jest.fn((lbl) => { + removedLabels.push(lbl.getName()) + }), + } + + const mockGmail = { + search: jest.fn(() => [thread]), + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + } + const mockClassifyFn = jest.fn(() => null) + + mod.processEmailsWithAiClassifier( + { + geminiApiKey: 'test-key', + processedLabel: 'Processed', + retryLabel: 'AI/Needs-Retry', + manualReviewLabel: 'AI/Manual-Review', + unprocessedQuery: 'label:AI/Needs-Retry', + }, + { + LockService: mockLockService, + GmailApp: mockGmail, + classifyFn: mockClassifyFn, + PropertiesService: { getScriptProperties: () => mockProps }, + CacheService: { getScriptCache: () => mockCache }, + } + ) + + expect(addedLabels).toContain('AI/Manual-Review') + expect(removedLabels).toContain('AI/Needs-Retry') + expect(addedLabels).not.toContain('Processed') + }) + + test('removes retryLabel and adds processedLabel when thread succeeds on subsequent run', () => { + const addedLabels = [] + const removedLabels = [] + const retryLabelObj = { getName: () => 'AI/Needs-Retry' } + mockCacheStore['retry_count_th-success-later'] = '2' + + const thread = { + getId: () => 'th-success-later', + getMessages: () => [ + { + getFrom: () => 'billing@powercompany.com', + getSubject: () => 'Electric Utility Bill Statement', + getPlainBody: () => 'Your bill is $145.00', + getDate: () => new Date(), + }, + ], + getLabels: () => [retryLabelObj], + addLabel: jest.fn((lbl) => { + addedLabels.push(lbl.getName()) + }), + removeLabel: jest.fn((lbl) => { + removedLabels.push(lbl.getName()) + }), + moveToArchive: jest.fn(), + } + + const mockGmail = { + search: jest.fn(() => [thread]), + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + } + const mockClassifyFn = jest.fn(() => ({ + canonicalDomain: '01_Household', + canonical_label: '01_Household', + subLabel: 'Household/Bills', + category: 'Updates', + action: 'archive', + confidence: 0.99, + title: 'Electric Bill', + summary: 'Monthly power statement', + })) + + mod.processEmailsWithAiClassifier( + { + geminiApiKey: 'test-key', + processedLabel: 'Processed', + retryLabel: 'AI/Needs-Retry', + manualReviewLabel: 'AI/Manual-Review', + unprocessedQuery: 'label:AI/Needs-Retry', + }, + { + LockService: mockLockService, + GmailApp: mockGmail, + classifyFn: mockClassifyFn, + PropertiesService: { getScriptProperties: () => mockProps }, + CacheService: { getScriptCache: () => mockCache }, + } + ) + + expect(removedLabels).toContain('AI/Needs-Retry') + expect(addedLabels).toContain('Processed') + expect( + mockCacheStore['retry_count_th-success-later'] + ).toBeUndefined() + }) + + test('skips remote API when circuit breaker is active but deterministic employment rules still succeed in 0ms', () => { + mockPropsStore['GEMINI_BACKOFF_UNTIL'] = String(Date.now() + 600000) + + const addedLabels = [] + const threadDet = { + getId: () => 'th-employment-det', + getMessages: () => [ + { + getFrom: () => 'hr@employer.com', + getSubject: () => 'Employment Agreement', + getPlainBody: () => + 'Here is your official employment agreement.', + getDate: () => new Date(), + }, + ], + getLabels: () => [], + addLabel: jest.fn((lbl) => { + addedLabels.push(lbl.getName()) + }), + removeLabel: jest.fn(), + } + + const mockGmail = { + search: jest.fn(() => [threadDet]), + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + } + const mockClassifyFn = jest.fn() + + mod.processEmailsWithAiClassifier( + { + geminiApiKey: 'test-key', + processedLabel: 'Processed', + retryLabel: 'AI/Needs-Retry', + manualReviewLabel: 'AI/Manual-Review', + unprocessedQuery: 'in:inbox', + }, + { + LockService: mockLockService, + GmailApp: mockGmail, + classifyFn: mockClassifyFn, + PropertiesService: { getScriptProperties: () => mockProps }, + CacheService: { getScriptCache: () => mockCache }, + } + ) + + expect(mockClassifyFn).not.toHaveBeenCalled() + expect(addedLabels).toContain('Work/Career') + expect(addedLabels).toContain('Processed') + }) + + test('runMissedEmailsSweep searches for recent unprocessed and retry-labeled threads', () => { + let capturedQuery = null + const mockGmail = { + search: jest.fn((q) => { + capturedQuery = q + return [] + }), + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + } + const res = mod.runMissedEmailsSweep({}, { GmailApp: mockGmail }) + expect(mockGmail.search).toHaveBeenCalled() + expect(capturedQuery).toBe( + 'label:AI/Needs-Retry OR (newer_than:14d -label:Processed -label:AI/Manual-Review)' + ) + expect(res.scanned).toBe(0) + }) + + test('revertAndQueueMissedCareerEmails reverts Processed and queues AI/Needs-Retry on unclassified career threads', () => { + const added = [] + const removed = [] + const processedLbl = { getName: () => 'Processed' } + const unclassifiedCareerThread = { + getId: () => 'th-career-unclass', + getLabels: () => [processedLbl], + removeLabel: jest.fn((l) => removed.push(l.getName())), + addLabel: jest.fn((l) => added.push(l.getName())), + } + const alreadyClassifiedThread = { + getId: () => 'th-career-done', + getLabels: () => [processedLbl, { getName: () => 'Work/Career' }], + removeLabel: jest.fn(), + addLabel: jest.fn(), + } + + const mockGmail = { + search: jest.fn(() => [ + unclassifiedCareerThread, + alreadyClassifiedThread, + ]), + getUserLabelByName: jest.fn((name) => ({ getName: () => name })), + } + + const modified = mod.revertAndQueueMissedCareerEmails(mockGmail, { + processedLabel: 'Processed', + retryLabel: 'AI/Needs-Retry', + }) + + expect(modified).toEqual(['th-career-unclass']) + expect(removed).toContain('Processed') + expect(added).toContain('AI/Needs-Retry') + expect(alreadyClassifiedThread.removeLabel).not.toHaveBeenCalled() + expect(alreadyClassifiedThread.addLabel).not.toHaveBeenCalled() + }) + }) + }) + }) + + describe('Comprehensive Edge Cases, Triggers, and Safety Shields', () => { + implementations.forEach(({ name, mod }) => { + describe(name, () => { + test('validateOntologicalClassification rejects invalid inputs, confidence, category, and action', () => { + expect(mod.validateOntologicalClassification(null)).toBe(false) + expect(mod.validateOntologicalClassification([])).toBe(false) + expect( + mod.validateOntologicalClassification({ confidence: 1.5 }) + ).toBe(false) + expect( + mod.validateOntologicalClassification({ confidence: -0.1 }) + ).toBe(false) + expect( + mod.validateOntologicalClassification({ confidence: 'high' }) + ).toBe(false) + expect( + mod.validateOntologicalClassification({ category: 'InvalidTab' }) + ).toBe(false) + expect( + mod.validateOntologicalClassification({ action: 'delete_all' }) + ).toBe(false) + expect( + mod.validateOntologicalClassification({ canonicalDomain: 123 }, [ + '01_Household', + ]) + ).toBe(false) + expect( + mod.validateOntologicalClassification( + { canonicalDomain: '99_Unknown' }, + ['01_Household'] + ) + ).toBe(false) + }) + + test('trigger setup functions manage ScriptApp triggers and handle missing ScriptApp', () => { + const origScriptApp = global.ScriptApp + try { + delete global.ScriptApp + expect(() => mod.setupWeeklyRetentionTrigger({})).not.toThrow() + expect(() => mod.setupDailyAuditTrigger({})).not.toThrow() + expect(() => mod.setupFourHourAuditTrigger({})).not.toThrow() + expect(() => mod.stopAllTriggers({})).not.toThrow() + expect(() => mod.setupFiveMinuteTrigger({})).not.toThrow() + } finally { + global.ScriptApp = origScriptApp + } + + const deleted = [] + const created = [] + const makeMockScriptApp = (withWeekDay = true) => ({ + WeekDay: withWeekDay ? { SUNDAY: 7 } : undefined, + getProjectTriggers: () => [ + { + getHandlerFunction: () => + 'purgeExpiredEmailsByRetentionPolicy', + }, + { getHandlerFunction: () => 'auditEmailClassifications' }, + { getHandlerFunction: () => 'otherHandler' }, + ], + deleteTrigger: (t) => deleted.push(t.getHandlerFunction()), + newTrigger: (fn) => { + const builder = { + timeBased: () => builder, + onWeekDay: () => builder, + atHour: () => builder, + everyDays: () => builder, + everyHours: () => builder, + everyMinutes: () => builder, + create: () => created.push(fn), + } + return builder + }, + }) + + mod.setupWeeklyRetentionTrigger({ + ScriptApp: makeMockScriptApp(true), + }) + mod.setupWeeklyRetentionTrigger({ + ScriptApp: makeMockScriptApp(false), + }) + mod.setupDailyAuditTrigger({ ScriptApp: makeMockScriptApp(true) }) + mod.setupFourHourAuditTrigger({ + ScriptApp: makeMockScriptApp(true), + }) + mod.stopAllTriggers({ ScriptApp: makeMockScriptApp(true) }) + mod.setupFiveMinuteTrigger({ ScriptApp: makeMockScriptApp(true) }) + mod.createGmailFilterRule('a@b.com', '01_Household') + + expect(deleted).toContain('purgeExpiredEmailsByRetentionPolicy') + expect(deleted).toContain('auditEmailClassifications') + expect(created).toContain('processEmailsWithAiClassifier') + }) + + test('classifyWithGemini handles open circuit breaker, missing UrlFetchApp, HTTP 500, and quota exception', () => { + const openProps = { + getProperty: () => String(Date.now() + 60000), + } + expect( + mod.classifyWithGemini( + 'a@b.com', + 'Subj', + 'Snip', + {}, + { properties: openProps } + ) + ).toBeNull() + + const origUrlFetch = global.UrlFetchApp + try { + delete global.UrlFetchApp + expect( + mod.classifyWithGemini('a@b.com', 'Subj', 'Snip', {}, {}) + ).toBeNull() + } finally { + global.UrlFetchApp = origUrlFetch + } + + // HTTP 500 + broken JSON + quota exception + let callIdx = 0 + const mockFetch = { + fetch: () => { + callIdx++ + if (callIdx === 1) { + return { + getResponseCode: () => 500, + getContentText: () => 'Internal Server Error', + } + } + if (callIdx === 2) { + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + candidates: [ + { content: { parts: [{ text: '{invalid json}' }] } }, + ], + }), + } + } + if (callIdx === 3) { + return { + getResponseCode: () => 429, + getHeaders: () => ({}), + getContentText: () => '{malformed429', + } + } + throw new Error('Service invoked too many times for one day') + }, + } + const origUtils = global.Utilities + let res + try { + delete global.Utilities + res = mod.classifyWithGemini( + 'a@b.com', + 'Subj', + 'Snip', + { modelEndpoint: 'https://custom.endpoint/v1' }, + { + UrlFetchApp: mockFetch, + CacheService: { + get: () => null, + put: () => {}, + }, + } + ) + } finally { + global.Utilities = origUtils + } + expect(res).toBeNull() + + // recordCircuitBreakerSuccess fallback when deleteProperty is missing + const setCalls = {} + mod.recordCircuitBreakerSuccess({ + setProperty: (k, v) => { + setCalls[k] = v + }, + }) + expect(setCalls.GEMINI_BACKOFF_FAILURES).toBe('0') + }) + + test('ensureGmailLabel, setGmailCategoryTab, and cleanConflictingLabels handle edge cases', () => { + expect(mod.ensureGmailLabel(null, null)).toBeNull() + + const existingLbl = { getName: () => 'Household/Property' } + const gmailCaseMatch = { + getUserLabelByName: () => null, + getUserLabels: () => [existingLbl], + } + expect( + mod.ensureGmailLabel('household/property', gmailCaseMatch) + ).toBe(existingLbl) + + const rootLbl = { getName: () => 'Household' } + let getLabelsCall = 0 + const gmailRaceRecover = { + getUserLabelByName: () => null, + getUserLabels: () => { + getLabelsCall++ + return getLabelsCall > 1 ? [rootLbl] : [] + }, + createLabel: () => { + throw new Error('Label name exists') + }, + } + expect(mod.ensureGmailLabel('household', gmailRaceRecover)).toBe( + rootLbl + ) + + // setGmailCategoryTab catches modify error on Primary + expect(() => + mod.setGmailCategoryTab( + { + getId: () => 'th-1', + getFirstMessageSubject: () => 'Subj', + }, + 'Primary', + { + Users: { + Threads: { + modify: () => { + throw new Error('API failure') + }, + }, + }, + } + ) + ).not.toThrow() + + // cleanConflictingLabels handles removeLabel throws and domain code labels + const badThread = { + getLabels: () => [ + { getName: () => 'Retention/Permanent' }, + { getName: () => '01_Household' }, + { getName: () => 'Custom_Domain' }, + { getName: () => 'Finance' }, + { getName: () => 'Finance/Taxes' }, + ], + removeLabel: () => { + throw new Error('Cannot remove') + }, + } + expect(() => + mod.cleanConflictingLabels( + badThread, + '01_Household', + 'Household/Property', + { + canonicalDomains: [ + '01_Household', + '02_Finance_Legal', + 'Custom_Domain', + ], + } + ) + ).not.toThrow() + + // cleanConflictingLabels outer catch when getLabels throws + expect(() => + mod.cleanConflictingLabels( + { + getLabels: () => { + throw new Error('getLabels failed') + }, + }, + '01_Household', + 'Household/Property', + {} + ) + ).not.toThrow() + }) + + test('deterministicRuleClassify_ matches familyEmails and processThreadBatch archives', () => { + const det = mod.deterministicRuleClassify_( + 'cousin@family.org', + 'Weekend plans', + 'Are you free on Saturday?', + null, + { familyEmails: 'cousin@family.org' } + ) + expect(det).not.toBeNull() + expect(det.canonicalDomain).toBe('04_Family_Health') + expect(det.subLabel).toBe('Family/Correspondence') + + const archived = [] + const batchThread = { + getId: () => 'th-batch-arch', + getMessages: () => [ + { + getFrom: () => 'alerts@broodminder.com', + getSubject: () => 'Events in your apiaries', + getPlainBody: () => 'Hive sensor telemetry report', + }, + ], + addLabel: jest.fn(), + moveToArchive: () => archived.push('th-batch-arch'), + } + mod.processThreadBatch( + [batchThread], + { + canonicalDomains: ['07_Community_NonProfit'], + processedLabel: 'Processed', + autoFilterConfidenceThreshold: 0.99, + }, + { + GmailApp: { + getUserLabelByName: (n) => ({ getName: () => n }), + }, + } + ) + expect(archived).toEqual(['th-batch-arch']) + expect(mod.extractTopicTitleFromPath('///')).toBe( + 'Ingested Records' + ) + expect(mod.executeGitHubCommit('', '')).toBe(false) + }) + + test('auditClassifications and auditEmailClassifications handle secondary categories, trash, and sendEmail errors', () => { + const trashThread = { + id: 'th-trash', + isInTrash: true, + getCategory: () => 'PROMOTIONS', + getMessages: () => [ + { + getFrom: () => 'promo@shop.com', + getSubject: () => 'Sale', + getPlainBody: () => '50% off', + }, + ], + getLabels: () => [{ getName: () => 'Processed' }], + } + const metaThread = { + id: 'th-meta', + getMessages: () => [ + { + getFrom: () => 'promo@shop.com', + getSubject: () => 'Sale 2', + getPlainBody: () => '50% off', + }, + ], + getLabels: () => [{ getName: () => 'Processed' }], + } + const report = mod.auditClassifications( + [trashThread, metaThread], + { canonicalDomains: ['01_Household'] }, + { + Gmail: { + Users: { + Threads: { + get: () => ({ labelIds: ['CATEGORY_PROMOTIONS'] }), + }, + }, + }, + } + ) + expect(report.scannedCount).toBe(2) + + // auditEmailClassifications catches sendEmail error + const badGmail = { + search: () => [ + { + getId: () => 'th-mismatch', + isInInbox: () => true, + getMessages: () => [ + { + getFrom: () => 'alerts@google.com', + getSubject: () => 'Google Alert - News', + getPlainBody: () => 'News digest', + }, + ], + getLabels: () => [{ getName: () => 'Family/School-Student' }], + }, + ], + sendEmail: () => { + throw new Error('Mail quota exceeded') + }, + } + expect(() => + mod.auditEmailClassifications( + { + auditDigestEmail: 'admin@example.com', + canonicalDomains: ['04_Family_Health'], + }, + { GmailApp: badGmail } + ) + ).not.toThrow() + }) + + test('attachment eligibility, subfolder resolution, zip extraction, and audit backfill edge cases', () => { + expect( + mod.resolveTaxonomySubfolderName( + '04_Family_Health', + 'Family/School-Medical' + ) + ).toBe('Medical_Records') + expect( + mod.resolveTaxonomySubfolderName( + '04_Family_Health', + 'Family/School' + ) + ).toBe('Students') + + expect( + mod.evaluateAttachmentEligibility({ + getName: () => 'disclaimer.txt', + getSize: () => 2048, + }).reason + ).toBe('TEXT_BOILERPLATE_DISCLAIMER') + expect( + mod.evaluateAttachmentEligibility({ + getName: () => 'custom.xyz', + getSize: () => 5000, + }).reason + ).toBe('UNKNOWN_OR_UNSUPPORTED_EXTENSION:.xyz') + + // persistCanonicalAttachmentsToDrive with folder.getFiles() returning conflict-renamed candidates + const folderWithGetFiles = { + getFilesByName: () => ({ hasNext: () => false }), + getFiles: () => { + const list = [ + { getName: () => '' }, + { + getName: () => 'invoice.pdf', + getSize: () => 2048, + getBytes: () => [9, 9, 9, 9], + }, + { + getName: () => 'invoice_999.pdf', + getSize: () => 2048, + getBytes: () => new Array(2048).fill(7), + }, + ] + let idx = 0 + return { + hasNext: () => idx < list.length, + next: () => list[idx++], + } + }, + createFile: () => ({ + getId: () => 'id-new', + setDescription: () => {}, + }), + } + const dupSaved = mod.persistCanonicalAttachmentsToDrive( + { + getId: () => 'th-dup-getfiles', + getMessages: () => [ + { + getAttachments: () => [ + { + getName: () => 'invoice.pdf', + getSize: () => 2048, + getBytes: () => new Array(2048).fill(7), + getContentType: () => 'application/pdf', + }, + ], + }, + ], + }, + { + canonicalDomain: '01_Household', + subLabel: 'Household/Property', + }, + { canonicalDomains: ['01_Household'] }, + { + DriveApp: { + getFoldersByName: () => ({ + hasNext: () => true, + next: () => ({ + getFoldersByName: () => ({ + hasNext: () => true, + next: () => folderWithGetFiles, + }), + }), + }), + }, + } + ) + expect(dupSaved).toEqual([]) + + // extractZipArchiveToSubfolder with metadata.sourceFileName, ineligible entry, and unzip error + const zipSubfolder = { + getFilesByName: () => ({ hasNext: () => false }), + createFile: () => ({ getId: () => 'id-1', getName: () => 'f' }), + } + const zipTargetFolder = { + getFoldersByName: () => ({ hasNext: () => false }), + createFolder: () => zipSubfolder, + } + mod.extractZipArchiveToSubfolder( + zipTargetFolder, + {}, + { + Utilities: { + unzip: () => [ + { + getName: () => 'logo.png', + getSize: () => 500, + getContentType: () => 'image/png', + }, + ], + }, + }, + { sourceFileName: 'packet.zip' } + ) + mod.extractZipArchiveToSubfolder( + zipTargetFolder, + { getName: () => 'broken.zip' }, + { + Utilities: { + unzip: () => { + throw new Error('Corrupt zip') + }, + }, + }, + {} + ) + + const origDrive = global.DriveApp + try { + delete global.DriveApp + expect(mod.unzipExistingDriveArchive('id-1', null, {})).toEqual( + [] + ) + expect(() => + mod.auditAndBackfillCanonicalAttachments({}, {}, {}) + ).toThrow(/GmailApp and DriveApp services are required/) + } finally { + global.DriveApp = origDrive + } + + // runDryRunAttachmentAudit with garbage + eligible items + getLabels() + const dryRunGmail = { + search: () => [ + { + getId: () => 'th-dry-1', + getFirstMessageSubject: () => 'Tax Packet', + getLabels: () => [ + { getName: () => '02_Finance_Legal' }, + { getName: () => '02_Finance_Legal/Taxes' }, + ], + getMessages: () => [ + { + getAttachments: (opts) => { + if (opts) throw new Error('opts not supported') + return [ + { + getName: () => 'smime.p7s', + getSize: () => 1024, + }, + { + getName: () => 'w2.pdf', + getBytes: () => new Array(1024).fill(1), + getContentType: () => 'application/pdf', + }, + ] + }, + }, + ], + }, + { + getId: () => 'th-dry-only-garbage', + getLabels: () => [{ getName: () => '01_Household' }], + getMessages: () => [ + { + getAttachments: () => [ + { getName: () => 'smime.p7s', getSize: () => 500 }, + ], + }, + ], + }, + ], + } + const dryRunDrive = { + getFoldersByName: () => ({ hasNext: () => false }), + } + const dryReport = mod.runDryRunAttachmentAudit( + {}, + { canonicalDomains: ['01_Household', '02_Finance_Legal'] }, + { GmailApp: dryRunGmail, DriveApp: dryRunDrive } + ) + expect(dryReport.totalEligibleAttachments).toBe(1) + expect(dryReport.filteredGarbageCount).toBe(2) + + // timeBudgetMs expiry in auditAndBackfillCanonicalAttachments + const expiredReport = mod.auditAndBackfillCanonicalAttachments( + { timeBudgetMs: -1 }, + { canonicalDomains: ['02_Finance_Legal'] }, + { GmailApp: dryRunGmail, DriveApp: dryRunDrive } + ) + expect(expiredReport.timeBudgetReached).toBe(true) + + // Live backfill with createFile throwing BACKFILL_ERROR and folder access error + let folderThrow = false + const errDrive = { + getFoldersByName: () => { + if (folderThrow) throw new Error('Drive error') + return { + hasNext: () => true, + next: () => ({ + getFoldersByName: () => ({ + hasNext: () => true, + next: () => ({ + getFilesByName: () => ({ hasNext: () => false }), + createFile: () => { + throw new Error('Quota exceeded') + }, + }), + }), + }), + } + }, + } + const liveErrReport = mod.auditAndBackfillCanonicalAttachments( + { dryRun: false }, + { canonicalDomains: ['02_Finance_Legal'] }, + { GmailApp: dryRunGmail, DriveApp: errDrive } + ) + expect(liveErrReport.items[0].status).toBe('BACKFILL_ERROR') + + folderThrow = true + const folderErrReport = mod.auditAndBackfillCanonicalAttachments( + { dryRun: false }, + { canonicalDomains: ['02_Finance_Legal'] }, + { GmailApp: dryRunGmail, DriveApp: errDrive } + ) + expect(folderErrReport.threadsWithEligibleAttachments).toBe(0) + }) + + test('reclassifyThreadsByQuery and runRealignmentAudit handle attachment verification, trash, and errors', () => { + const origGmail = global.GmailApp + try { + delete global.GmailApp + expect(() => + mod.reclassifyThreadsByQuery('in:inbox', {}, {}, {}) + ).toThrow(/GmailApp service is required/) + } finally { + global.GmailApp = origGmail + } + + // Reclassify with Thread-ID mismatch, size mismatch, hash match, and trash action + const movedFiles = [] + const oldFolder = { + getFilesByName: () => { + const files = [ + { + getName: () => 'doc.pdf', + getDescription: () => 'Thread-ID: other-thread', + moveTo: jest.fn(), + }, + { + getName: () => 'doc.pdf', + getDescription: () => '', + getSize: () => 9999, + moveTo: jest.fn(), + }, + { + getName: () => 'doc.pdf', + getDescription: () => '', + getSize: () => 2048, + getBytes: () => [1, 2, 3, 4], + moveTo: () => movedFiles.push('doc.pdf'), + }, + ] + let idx = 0 + return { + hasNext: () => idx < files.length, + next: () => files[idx++], + } + }, + } + const newFolder = { + getFilesByName: () => ({ hasNext: () => false }), + createFile: () => ({ + getId: () => 'saved-1', + setDescription: () => {}, + }), + } + const rootDrive = { + getFoldersByName: (name) => ({ + hasNext: () => true, + next: () => ({ + getFoldersByName: () => ({ + hasNext: () => true, + next: () => + name === '01_Household' ? oldFolder : newFolder, + }), + }), + }), + } + const trashedThreads = [] + const reclGmail = { + getUserLabels: () => { + throw new Error('Label list error') + }, + getUserLabelByName: (n) => ({ getName: () => n }), + search: () => [ + { + getId: () => 'th-recl-1', + getLastMessageDate: () => '2026-03-01T00:00:00Z', + getLabels: () => [{ getName: () => '01_Household' }], + getMessages: () => [ + { + getFrom: () => 'hr@corp.com', + getSubject: () => 'Career Opportunity', + getPlainBody: () => 'Details enclosed', + getAttachments: () => [ + { + getName: () => 'doc.pdf', + getSize: () => 2048, + getBytes: () => [1, 2, 3, 4], + getContentType: () => 'application/pdf', + }, + ], + }, + ], + addLabel: jest.fn(), + }, + { + getId: () => 'th-recl-trash', + getLastMessageDate: () => '2026-02-01T00:00:00Z', + getLabels: () => [], + getMessages: () => [ + { + getFrom: () => 'spam@scam.com', + getSubject: () => 'Random Message', + getPlainBody: () => 'Click here', + }, + ], + addLabel: jest.fn(), + moveToTrash: () => trashedThreads.push('th-recl-trash'), + }, + ], + } + mod.reclassifyThreadsByQuery( + 'in:inbox', + { dryRun: false }, + { + canonicalDomains: ['01_Household', '06_Work_Career'], + processedLabel: 'Processed', + }, + { + GmailApp: reclGmail, + DriveApp: rootDrive, + classifyFn: (_s, subj) => + subj.startsWith('Career Opportunity') + ? { + canonicalDomain: '06_Work_Career', + subLabel: 'Work/Career', + category: 'Primary', + action: 'keep', + confidence: 0.95, + } + : { + canonicalDomain: null, + category: 'Promotions', + action: 'trash', + confidence: 0.95, + }, + } + ) + expect(movedFiles).toEqual(['doc.pdf']) + expect(trashedThreads).toEqual(['th-recl-trash']) + + // timeBudgetMs expiry in reclassifyThreadsByQuery + const timedOutRecl = mod.reclassifyThreadsByQuery( + 'in:inbox', + { timeBudgetMs: -1 }, + {}, + { GmailApp: reclGmail } + ) + expect(timedOutRecl.reclassified).toBe(0) + + // runRealignmentAudit with getUserLabels error falls back to default query + const auditRes = mod.runRealignmentAudit( + '', + {}, + {}, + { + GmailApp: reclGmail, + classifyFn: () => { + throw new Error('Classifier failure') + }, + } + ) + expect(auditRes.query).toContain('Family/Sisters') + }) + + test('purgeExpiredEmailsByRetentionPolicy and processEmailsWithAiClassifier handle shields, pagination, and actions', () => { + const origGmail = global.GmailApp + try { + delete global.GmailApp + expect(mod.purgeExpiredEmailsByRetentionPolicy({}, {})).toEqual({ + trashedCount: 0, + exemptedCount: 0, + }) + } finally { + global.GmailApp = origGmail + } + + // 0 threads on page 0 + expect( + mod.purgeExpiredEmailsByRetentionPolicy( + { triggerUid: 'trig-1' }, + { GmailApp: { search: () => [] } } + ) + ).toEqual({ trashedCount: 0, exemptedCount: 0 }) + + // 50 threads on page 0 (1 with core domain label, 49 trashed) + 0 on page 1 + const page0Threads = Array.from({ length: 50 }, (_, idx) => ({ + getId: () => `th-p-${idx}`, + getLabels: () => + idx === 0 ? [{ getName: () => '01_Household' }] : [], + getMessages: () => [], + moveToTrash: jest.fn(), + })) + const pagedRes = mod.purgeExpiredEmailsByRetentionPolicy( + {}, + { + GmailApp: { + search: (_q, offset) => (offset === 0 ? page0Threads : []), + }, + } + ) + expect(pagedRes).toEqual({ trashedCount: 49, exemptedCount: 1 }) + + expect(mod.isDestructiveActionAllowed_(null)).toBe(false) + expect( + mod.isDestructiveActionAllowed_({ + action: 'trash', + confidence: NaN, + }) + ).toBe(false) + + // processEmailsWithAiClassifier missing geminiApiKey and missing GmailApp + mod.processEmailsWithAiClassifier({ geminiApiKey: '' }, {}) + const origGmail2 = global.GmailApp + try { + delete global.GmailApp + mod.processEmailsWithAiClassifier({ geminiApiKey: 'key' }, {}) + } finally { + global.GmailApp = origGmail2 + } + + // processEmailsWithAiClassifier with low-confidence shield, trash, mark_read, and removeLabel error + const trashed = [] + const markedRead = [] + const threads = [ + { + getId: () => 'th-low-conf', + getLabels: () => [], + getMessages: () => [ + { + getFrom: () => 'sender1@unknown.org', + getSubject: () => 'Unmapped Topic Alpha', + getPlainBody: () => 'Body', + }, + ], + addLabel: jest.fn(), + moveToTrash: jest.fn(), + moveToArchive: jest.fn(), + }, + { + getId: () => 'th-high-trash', + getLabels: () => [{ getName: () => 'AI/Needs-Retry' }], + getMessages: () => [ + { + getFrom: () => 'sender2@unknown.org', + getSubject: () => 'Unmapped Topic Beta', + getPlainBody: () => 'Body', + }, + ], + addLabel: jest.fn(), + removeLabel: () => { + throw new Error('Remove label error') + }, + moveToTrash: () => trashed.push('th-high-trash'), + markRead: () => markedRead.push('th-high-trash'), + }, + ] + mod.processEmailsWithAiClassifier( + { + geminiApiKey: 'test-key', + userAccountEmail: 'me@example.com', + processedLabel: 'Processed', + retryLabel: 'AI/Needs-Retry', + }, + { + GmailApp: { + search: () => threads, + getUserLabelByName: (n) => ({ getName: () => n }), + }, + classifyFn: (_s, subj) => + subj.startsWith('Unmapped Topic Alpha') + ? { + canonicalDomain: null, + category: 'Promotions', + action: 'trash', + confidence: 0.5, + } + : { + canonicalDomain: null, + category: 'Promotions', + action: 'trash', + markRead: true, + confidence: 0.95, + }, + } + ) + expect(threads[0].moveToTrash).not.toHaveBeenCalled() + expect(trashed).toEqual(['th-high-trash']) + expect(markedRead).toEqual(['th-high-trash']) + + // NonProfit/General resolution and attachment persistence failure retry queuing + expect( + mod.resolveCanonicalDomainFromLabels(['NonProfit/General']) + ).toBe('07_Community_NonProfit') + expect( + mod.resolveTaxonomySubfolderName( + '07_Community_NonProfit', + 'NonProfit/General' + ) + ).toBe('Community_BOD') + + const attFailLabels1 = [] + const attFailLabels2 = [] + const failingDriveApp = { + getRootFolder: () => ({ + getFoldersByName: () => ({ + hasNext: () => true, + next: () => ({ + getFoldersByName: () => ({ + hasNext: () => true, + next: () => ({ + getFilesByName: () => ({ hasNext: () => false }), + createFile: () => { + throw new Error('Drive quota exceeded') + }, + }), + }), + }), + }), + }), + } + const makeAttThread = (id, sink) => ({ + getId: () => id, + getLabels: () => [], + getMessages: () => [ + { + getFrom: () => 'billing@charitywater.org', + getSubject: () => 'Annual Receipt', + getPlainBody: () => 'Body', + getAttachments: () => [ + { + getName: () => 'receipt.pdf', + getContentType: () => 'application/pdf', + getSize: () => 2048, + getBytes: () => [1, 2, 3, 4], + }, + ], + }, + ], + addLabel: (l) => sink.push(l.getName()), + moveToArchive: jest.fn(), + }) + const retryCounts = { 'retry_count_th-att-fail-5': '4' } + mod.processEmailsWithAiClassifier( + { + geminiApiKey: 'test-key', + userAccountEmail: 'me@example.com', + processedLabel: 'Processed', + retryLabel: 'AI/Needs-Retry', + manualReviewLabel: 'AI/Manual-Review', + }, + { + GmailApp: { + search: () => [ + makeAttThread('th-att-fail-1', attFailLabels1), + makeAttThread('th-att-fail-5', attFailLabels2), + ], + getUserLabelByName: (n) => ({ getName: () => n }), + }, + DriveApp: failingDriveApp, + cache: { + get: (k) => retryCounts[k] || null, + put: (k, v) => { + retryCounts[k] = v + }, + }, + } + ) + expect(attFailLabels1).toContain('AI/Needs-Retry') + expect(attFailLabels1).not.toContain('Processed') + expect(attFailLabels2).toContain('AI/Manual-Review') + expect(attFailLabels2).not.toContain('Processed') + + expect( + mod.deterministicRuleClassify_( + 'alerts@chase.com', + 'AutoPay failed — action required', + 'Please submit manual payment.' + ) + ).toBeNull() + expect( + mod.deterministicRuleClassify_( + 'support@bestbuy.com', + 'Action required: verify your account', + 'Please verify your Best Buy account.' + ) + ).toBeNull() + expect( + mod.deterministicRuleClassify_( + 'promotions@bestbuy.com', + 'Weekend update from Best Buy', + 'Check out what is new this week.' + ) + ).toEqual( + expect.objectContaining({ + category: 'Promotions', + action: 'archive', + reasoning: 'Retail Promo', + }) + ) + expect( + mod.deterministicRuleClassify_( + 'results@questdiagnostics.com', + 'Action required: schedule follow-up for abnormal results', + 'Please contact your physician.' + ) + ).toBeNull() + expect( + mod.evaluateAttachmentEligibility({ + getName: () => 'signed-employment-agreement.jpg', + getContentType: () => 'image/jpeg', + getSize: () => 50 * 1024, + }).eligible + ).toBe(true) + }) + }) + }) + }) + }) }) diff --git a/src/gmail-ai-classifier/tests/drive-attachments.test.js b/src/gmail-ai-classifier/tests/drive-attachments.test.js new file mode 100644 index 00000000..d9e75518 --- /dev/null +++ b/src/gmail-ai-classifier/tests/drive-attachments.test.js @@ -0,0 +1,1822 @@ +const { + isCanonicalClassification, + resolveTaxonomySubfolderName, + evaluateAttachmentEligibility, + isEligibleAttachment, + getMessageAttachments_, + ensureDriveTaxonomyFolder, + isDuplicateAttachment, + resolveAttachmentName, + extractZipArchiveToSubfolder, + unzipExistingDriveArchive, + persistCanonicalAttachmentsToDrive, + formatProgressiveDisclosureEntry, + auditClassifications, + auditAndBackfillCanonicalAttachments, + runDryRunAttachmentAudit, + runLiveAttachmentBackfill, + resolveCanonicalDomainFromLabels, +} = require('../src/index.js') + +// Helper mock makers +const createMockFolder = (name = 'folder', id = 'f-' + name) => { + const subfolders = new Map() + const files = new Map() + + return { + getName: () => name, + getId: () => id, + getFoldersByName: jest.fn((subName) => { + const found = subfolders.get(subName) + let yielded = false + return { + hasNext: () => !yielded && !!found, + next: () => { + yielded = true + return found + }, + } + }), + createFolder: jest.fn((subName) => { + const newSub = createMockFolder(subName, id + '/' + subName) + subfolders.set(subName, newSub) + return newSub + }), + getFilesByName: jest.fn((fileName) => { + const list = files.get(fileName) || [] + let idx = 0 + return { + hasNext: () => idx < list.length, + next: () => list[idx++], + } + }), + createFile: jest.fn((blob) => { + const fileName = blob.getName ? blob.getName() : blob.name || 'file' + const fileId = + 'file-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6) + let fileDescription = '' + const mockFile = { + getName: () => fileName, + getId: () => fileId, + getUrl: () => 'https://drive.google.com/file/d/' + fileId, + getSize: () => + blob.getBytes + ? blob.getBytes().length + : blob.bytes + ? blob.bytes.length + : 0, + getBlob: () => blob, + setDescription: jest.fn((desc) => { + fileDescription = desc + }), + getDescription: jest.fn(() => fileDescription), + } + if (!files.has(fileName)) { + files.set(fileName, []) + } + files.get(fileName).push(mockFile) + return mockFile + }), + _subfolders: subfolders, + _files: files, + } +} + +const createMockDriveApp = () => { + const rootFolder = createMockFolder('root', 'root') + return { + getRootFolder: () => rootFolder, + } +} + +const createMockAttachment = ({ + name = 'statement.pdf', + contentType = 'application/pdf', + content = 'Sample PDF document content for testing', + size = null, +} = {}) => { + const bytes = Buffer.from(content, 'utf-8') + let currentName = name + + return { + getName: () => currentName, + setName: (n) => { + currentName = n + }, + getContentType: () => contentType, + getBytes: () => bytes, + getSize: () => (size !== null ? size : bytes.length), + copyBlob: function () { + return this + }, + bytes: bytes, + } +} + +describe('Drive Attachment Persistence along Taxonomy Path', () => { + describe('isCanonicalClassification', () => { + test('returns true for 7 standard canonical domains', () => { + const domains = [ + '01_Household', + '02_Finance_Legal', + '03_Vehicles', + '04_Family_Health', + '05_Tech_Infrastructure', + '06_Work_Career', + '07_Community_NonProfit', + ] + domains.forEach((d) => { + expect(isCanonicalClassification({ canonicalDomain: d })).toBe(true) + expect(isCanonicalClassification({ canonical_label: d })).toBe(true) + }) + }) + + test('returns true for compound domain labels', () => { + expect( + isCanonicalClassification({ + canonical_label: '02_Finance_Legal/Banking', + }) + ).toBe(true) + expect( + isCanonicalClassification({ + canonicalDomain: '01_Household/Primary_House', + }) + ).toBe(true) + }) + + test('returns false for null, undefined, empty, or string "null"', () => { + expect(isCanonicalClassification(null)).toBe(false) + expect(isCanonicalClassification({})).toBe(false) + expect(isCanonicalClassification({ canonicalDomain: null })).toBe(false) + expect(isCanonicalClassification({ canonicalDomain: '' })).toBe(false) + expect(isCanonicalClassification({ canonicalDomain: 'null' })).toBe(false) + expect(isCanonicalClassification({ canonicalDomain: 'undefined' })).toBe( + false + ) + }) + + test('returns false for non-canonical categories (promotions, newsletters, spam)', () => { + expect( + isCanonicalClassification({ + canonicalDomain: null, + category: 'Promotions', + }) + ).toBe(false) + expect( + isCanonicalClassification({ + canonicalDomain: 'Unknown_Category', + }) + ).toBe(false) + }) + }) + + describe('resolveTaxonomySubfolderName', () => { + test('resolves known sublabels to standardized subfolders', () => { + expect( + resolveTaxonomySubfolderName('02_Finance_Legal', 'Finance/Banking') + ).toBe('Banking') + expect( + resolveTaxonomySubfolderName('02_Finance_Legal', 'Finance/Bills') + ).toBe('Bills') + expect( + resolveTaxonomySubfolderName('02_Finance_Legal', 'Finance/Taxes') + ).toBe('Taxes') + expect( + resolveTaxonomySubfolderName( + '01_Household', + 'Household/Primary-Property' + ) + ).toBe('Primary_House') + expect( + resolveTaxonomySubfolderName('03_Vehicles', 'Vehicles/Maintenance') + ).toBe('Maintenance') + expect( + resolveTaxonomySubfolderName('04_Family_Health', 'Family/Medical') + ).toBe('Medical_Records') + expect( + resolveTaxonomySubfolderName( + '05_Tech_Infrastructure', + 'Tech/Alerts-Monitoring' + ) + ).toBe('NAS_Backups') + }) + + test('resolves ontology prompt sublabels (Family/Correspondence, Family/Legal, Tech/Cloud, Work/Employer)', () => { + expect( + resolveTaxonomySubfolderName( + '04_Family_Health', + 'Family/Correspondence' + ) + ).toBe('Family_General') + expect( + resolveTaxonomySubfolderName('04_Family_Health', 'Family/Legal') + ).toBe('Family_General') + expect( + resolveTaxonomySubfolderName('05_Tech_Infrastructure', 'Tech/Cloud') + ).toBe('NAS_Backups') + expect( + resolveTaxonomySubfolderName('05_Tech_Infrastructure', 'Tech/Security') + ).toBe('Hardware_Licenses') + expect( + resolveTaxonomySubfolderName('05_Tech_Infrastructure', 'Tech/Alerts') + ).toBe('NAS_Backups') + expect( + resolveTaxonomySubfolderName('06_Work_Career', 'Work/Employer') + ).toBe('Career_Interviews') + expect( + resolveTaxonomySubfolderName('06_Work_Career', 'Work/Career') + ).toBe('Career_Interviews') + expect(resolveTaxonomySubfolderName('06_Work_Career', 'Career/DJ')).toBe( + 'Career_DJ' + ) + expect( + resolveTaxonomySubfolderName('06_Work_Career', 'Career/Rachel') + ).toBe('Career_Rachel') + expect( + resolveTaxonomySubfolderName('06_Work_Career', 'Career/Employer') + ).toBe('Employer') + expect( + resolveTaxonomySubfolderName('02_Finance_Legal', 'Finance/Donations') + ).toBe('Taxes') + }) + + test('sanitizes unknown sublabels cleanly', () => { + expect( + resolveTaxonomySubfolderName( + '02_Finance_Legal', + 'Finance/Direct-Deposit' + ) + ).toBe('Direct_Deposit') + expect( + resolveTaxonomySubfolderName('01_Household', 'Remodeling Bids') + ).toBe('Remodeling_Bids') + }) + + test('falls back to default subfolder per domain when subLabel is omitted', () => { + expect(resolveTaxonomySubfolderName('01_Household')).toBe('Primary_House') + expect(resolveTaxonomySubfolderName('02_Finance_Legal')).toBe('Banking') + expect(resolveTaxonomySubfolderName('03_Vehicles')).toBe('Maintenance') + expect(resolveTaxonomySubfolderName('04_Family_Health')).toBe( + 'Medical_Records' + ) + expect(resolveTaxonomySubfolderName('05_Tech_Infrastructure')).toBe( + 'NAS_Backups' + ) + expect(resolveTaxonomySubfolderName('06_Work_Career')).toBe( + 'Career_Interviews' + ) + expect(resolveTaxonomySubfolderName('07_Community_NonProfit')).toBe( + 'Community_BOD' + ) + }) + + test('resolves school student sublabels to Students directory', () => { + expect( + resolveTaxonomySubfolderName( + '04_Family_Health', + 'Family/School-Charley' + ) + ).toBe('Students') + expect( + resolveTaxonomySubfolderName('04_Family_Health', 'Family/School-Toby') + ).toBe('Students') + expect( + resolveTaxonomySubfolderName('04_Family_Health', 'Family/School-Tide') + ).toBe('Students') + }) + + test('resolves family correspondence and parent sublabels to Family_General directory', () => { + expect( + resolveTaxonomySubfolderName( + '04_Family_Health', + 'Family/Personal-Correspondence' + ) + ).toBe('Family_General') + expect( + resolveTaxonomySubfolderName('04_Family_Health', 'Family/Parents') + ).toBe('Family_General') + expect( + resolveTaxonomySubfolderName( + '04_Family_Health', + 'Family/Parents/Household' + ) + ).toBe('Family_General') + }) + }) + + describe('isEligibleAttachment', () => { + test('returns false for null or empty attachments', () => { + expect(isEligibleAttachment(null)).toBe(false) + expect(isEligibleAttachment({})).toBe(false) + expect(isEligibleAttachment({ getName: () => '' })).toBe(false) + }) + + test('returns false for zero-byte attachments', () => { + const emptyAtt = createMockAttachment({ + name: 'empty.pdf', + content: '', + size: 0, + }) + expect(isEligibleAttachment(emptyAtt)).toBe(false) + }) + + test('filters out small image files (< 15KB) as signature/tracking pixels', () => { + const pixelImg = createMockAttachment({ + name: 'icon.png', + contentType: 'image/png', + content: 'small', + size: 2048, // 2KB + }) + expect(isEligibleAttachment(pixelImg)).toBe(false) + + const sigImg = createMockAttachment({ + name: 'signature.jpg', + contentType: 'image/jpeg', + content: 'sig', + size: 8192, // 8KB + }) + expect(isEligibleAttachment(sigImg)).toBe(false) + }) + + test('filters out known tracking names (< 25KB)', () => { + const trackingAtt = createMockAttachment({ + name: 'image001.png', + contentType: 'image/png', + content: 'img', + size: 18000, // 18KB (< 25KB) + }) + expect(isEligibleAttachment(trackingAtt)).toBe(false) + }) + + test('supports getBytes, bytes, and size fallbacks', () => { + const attWithGetBytes = { + getName: () => 'document.pdf', + getBytes: () => Buffer.from('hello-world-12345'), + } + expect(isEligibleAttachment(attWithGetBytes)).toBe(true) + + const attWithEmptyBytes = { + getName: () => 'empty.pdf', + getBytes: () => null, + } + expect(isEligibleAttachment(attWithEmptyBytes)).toBe(false) + + const attWithBytesProp = { + getName: () => 'document2.pdf', + bytes: Buffer.from('hello-world-12345'), + } + expect(isEligibleAttachment(attWithBytesProp)).toBe(true) + + const attWithSizeProp = { + name: 'document3.pdf', + size: 5000, + } + expect(isEligibleAttachment(attWithSizeProp)).toBe(true) + + const smallNonTrackingImage = { + name: 'other_small.png', + contentType: 'image/png', + size: 5000, + } + expect(isEligibleAttachment(smallNonTrackingImage)).toBe(false) + }) + + test('accepts document attachments of all types', () => { + const docs = [ + 'water_bill.pdf', + 'contract.docx', + 'payroll.xlsx', + 'export.csv', + 'notes.txt', + 'archive.zip', + ] + docs.forEach((d) => { + const att = createMockAttachment({ name: d, size: 5000 }) + expect(isEligibleAttachment(att)).toBe(true) + }) + }) + + test('accepts substantive image attachments (>= 35KB) without signature names', () => { + const photoAtt = createMockAttachment({ + name: 'receipt_scan.jpg', + contentType: 'image/jpeg', + content: 'real photo data', + size: 45000, // 45KB + }) + expect(isEligibleAttachment(photoAtt)).toBe(true) + }) + + test('strictly rejects non-document extensions (.ics, .vcf, .html, .dat, .p7s)', () => { + const nonDocs = [ + 'meeting.ics', + 'invite.ical', + 'contact.vcf', + 'newsletter.html', + 'email.htm', + 'winmail.dat', + 'smime.p7s', + 'app.exe', + ] + nonDocs.forEach((f) => { + const att = createMockAttachment({ name: f, size: 50000 }) + const res = evaluateAttachmentEligibility(att) + expect(res.eligible).toBe(false) + expect(res.reason).toMatch(/BLOCKED/) + }) + }) + + test('strictly rejects signature, logo, and banner image names even if >= 35KB', () => { + const sigs = [ + 'company_logo.png', + 'my_signature.jpg', + 'email_header.png', + 'footer_banner.jpg', + 'social_facebook.png', + 'outlook-1a2b3c.png', + 'unnamed.png', + ] + sigs.forEach((f) => { + const att = createMockAttachment({ + name: f, + contentType: 'image/png', + size: 60000, // 60KB + }) + const res = evaluateAttachmentEligibility(att) + expect(res.eligible).toBe(false) + expect(res.reason).toContain('SIGNATURE_OR_LOGO_PATTERN') + }) + }) + + test('strictly rejects attachments exceeding GAS payload limit (>25MB)', () => { + const oversizeAtt = createMockAttachment({ + name: 'large_archive.zip', + size: 26 * 1024 * 1024, + }) + const res = evaluateAttachmentEligibility(oversizeAtt) + expect(res.eligible).toBe(false) + expect(res.reason).toBe('EXCEEDS_GAS_SIZE_LIMIT (>25MB)') + expect(isEligibleAttachment(oversizeAtt)).toBe(false) + }) + + test('getMessageAttachments_ requests non-inline attachments', () => { + const mockMsg = { + getAttachments: jest.fn((options) => { + if (options && options.includeInlineImages === false) { + return [{ name: 'real_doc.pdf' }] + } + return [{ name: 'real_doc.pdf' }, { name: 'inline_logo.png' }] + }), + } + + const atts = getMessageAttachments_(mockMsg) + expect(mockMsg.getAttachments).toHaveBeenCalledWith({ + includeInlineImages: false, + includeAttachments: true, + }) + expect(atts).toHaveLength(1) + expect(atts[0].name).toBe('real_doc.pdf') + }) + }) + + describe('ensureDriveTaxonomyFolder', () => { + test('creates 2-level taxonomy folder idempotently', () => { + const driveApp = createMockDriveApp() + const folder = ensureDriveTaxonomyFolder( + '02_Finance_Legal', + 'Banking', + driveApp + ) + + expect(folder).toBeDefined() + expect(folder.getName()).toBe('Banking') + + // Call second time to verify idempotency + const folder2 = ensureDriveTaxonomyFolder( + '02_Finance_Legal', + 'Banking', + driveApp + ) + expect(folder2).toBe(folder) + }) + + test('throws error if driveApp is unavailable', () => { + expect(() => + ensureDriveTaxonomyFolder('01_Household', 'Bills', null) + ).toThrow('DriveApp service unavailable') + }) + + test('returns domain folder when subfolderName is empty or omitted', () => { + const driveApp = createMockDriveApp() + const folder = ensureDriveTaxonomyFolder('01_Household', '', driveApp) + expect(folder).toBeDefined() + expect(folder.getName()).toBe('01_Household') + }) + + test('throws error when root folder is invalid', () => { + expect(() => + ensureDriveTaxonomyFolder('01_Household', 'Bills', {}) + ).toThrow('DriveApp service unavailable or invalid root folder') + }) + }) + + describe('isDuplicateAttachment & resolveAttachmentName', () => { + test('identifies exact duplicate file by size and MD5 hash', () => { + const folder = createMockFolder('Bills') + const blob1 = createMockAttachment({ + name: 'water_bill.pdf', + content: 'Water bill content 12345', + }) + folder.createFile(blob1) + + const existingFiles = folder.getFilesByName('water_bill.pdf') + const blobSame = createMockAttachment({ + name: 'water_bill.pdf', + content: 'Water bill content 12345', + }) + + expect(isDuplicateAttachment(existingFiles, blobSame)).toBe(true) + }) + + test('returns false when content differs even if name is identical', () => { + const folder = createMockFolder('Bills') + const blob1 = createMockAttachment({ + name: 'water_bill.pdf', + content: 'Water bill content for August', + }) + folder.createFile(blob1) + + const existingFiles = folder.getFilesByName('water_bill.pdf') + const blobDiff = createMockAttachment({ + name: 'water_bill.pdf', + content: 'Water bill content for September (different)', + }) + + expect(isDuplicateAttachment(existingFiles, blobDiff)).toBe(false) + }) + + test('returns false when existingFiles is missing or has no hasNext', () => { + expect(isDuplicateAttachment(null, createMockAttachment())).toBe(false) + expect(isDuplicateAttachment({}, createMockAttachment())).toBe(false) + }) + + test('supports newFileBlob as Buffer or object with bytes or null', () => { + const folder = createMockFolder('Bills') + const existing = folder.getFilesByName('file.pdf') + const buf = Buffer.from('test') + expect(isDuplicateAttachment(existing, buf)).toBe(false) + expect(isDuplicateAttachment(existing, { bytes: buf })).toBe(false) + expect(isDuplicateAttachment(existing, null)).toBe(false) + }) + + test('checks size property on existing file if getSize is missing', () => { + const mockExisting = { + hasNext: jest.fn().mockReturnValueOnce(true).mockReturnValueOnce(false), + next: () => ({ + size: 10, + getBlob: () => ({ getBytes: () => Buffer.from('1234567890') }), + }), + } + const newBlob = { + getBytes: () => Buffer.from('1234567890'), + } + expect(isDuplicateAttachment(mockExisting, newBlob)).toBe(true) + }) + + test('handles fallback when blob or file has no size or byte accessors', () => { + const mockFolder = createMockFolder('Bills') + const existing = mockFolder.getFilesByName('file.pdf') + expect(isDuplicateAttachment(existing, {})).toBe(false) + + const faultyBlob = { + getBytes: () => { + throw new Error('Hash calculation error') + }, + } + expect(isDuplicateAttachment(existing, faultyBlob)).toBe(false) + + const fileWithoutSizeOrGetSize = { + hasNext: jest.fn().mockReturnValueOnce(true).mockReturnValueOnce(false), + next: () => ({ + getBlob: () => ({ bytes: Buffer.from('') }), + }), + } + expect( + isDuplicateAttachment(fileWithoutSizeOrGetSize, { + bytes: Buffer.from(''), + }) + ).toBe(true) + }) + + test('resolveAttachmentName appends timestamp when file of same name exists with different content', () => { + const folder = createMockFolder('Bills') + const blob1 = createMockAttachment({ + name: 'bill.pdf', + content: 'A', + }) + folder.createFile(blob1) + + const blob2 = createMockAttachment({ + name: 'bill.pdf', + content: 'B', + }) + const resolved = resolveAttachmentName(folder, 'bill.pdf', blob2, {}) + expect(resolved).not.toBe('bill.pdf') + expect(resolved).toMatch(/^bill_[\w-]+\.pdf$/) + expect(blob2.getName()).toBe(resolved) + }) + + test('resolveAttachmentName safely returns fileName when folder.getFilesByName returns null or invalid', () => { + const folderNull = { + getFilesByName: jest.fn(() => null), + } + expect(resolveAttachmentName(folderNull, 'bill.pdf', null)).toBe( + 'bill.pdf' + ) + + const folderNoHasNext = { + getFilesByName: jest.fn(() => ({})), + } + expect(resolveAttachmentName(folderNoHasNext, 'bill.pdf', null)).toBe( + 'bill.pdf' + ) + }) + }) + + describe('persistCanonicalAttachmentsToDrive', () => { + test('STRICT NON-CANONICAL GATE: strictly skips attachments for non-canonical emails', () => { + const driveApp = createMockDriveApp() + const thread = { + getMessages: () => [ + { + getAttachments: () => [ + createMockAttachment({ name: 'coupon.pdf', content: 'discount' }), + ], + }, + ], + } + const nonCanonicalClassification = { + canonicalDomain: null, + category: 'Promotions', + action: 'archive', + } + const config = { + canonicalDomains: ['01_Household', '02_Finance_Legal'], + } + + const results = persistCanonicalAttachmentsToDrive( + thread, + nonCanonicalClassification, + config, + { DriveApp: driveApp } + ) + + expect(results).toEqual([]) + // Verify nothing created in drive + const root = driveApp.getRootFolder() + expect(root._subfolders.size).toBe(0) + }) + + test('persists eligible attachments to correct taxonomy path for canonical emails', () => { + const driveApp = createMockDriveApp() + const thread = { + getMessages: () => [ + { + getAttachments: () => [ + createMockAttachment({ + name: 'caw_funding_details.pdf', + content: 'Central Alabama Water Funding Details', + }), + createMockAttachment({ + name: 'logo.png', + contentType: 'image/png', + content: 'small', + size: 1024, // 1KB tracking pixel -> must be ignored! + }), + ], + }, + ], + } + const canonicalClassification = { + canonicalDomain: '02_Finance_Legal', + subLabel: 'Finance/Banking', + confidence: 0.98, + } + const config = { + canonicalDomains: ['01_Household', '02_Finance_Legal'], + } + + const results = persistCanonicalAttachmentsToDrive( + thread, + canonicalClassification, + config, + { DriveApp: driveApp } + ) + + expect(results).toHaveLength(1) + expect(results[0].name).toBe('caw_funding_details.pdf') + expect(results[0].domain).toBe('02_Finance_Legal') + expect(results[0].subfolder).toBe('Banking') + expect(results[0].url).toContain('https://drive.google.com/file/d/') + + // Verify folder hierarchy + const root = driveApp.getRootFolder() + const domainFolder = root._subfolders.get('02_Finance_Legal') + expect(domainFolder).toBeDefined() + const subFolder = domainFolder._subfolders.get('Banking') + expect(subFolder).toBeDefined() + expect(subFolder._files.get('caw_funding_details.pdf')).toHaveLength(1) + expect(subFolder._files.has('logo.png')).toBe(false) + }) + + test('deduplicates when same attachment is re-processed', () => { + const driveApp = createMockDriveApp() + const att = createMockAttachment({ + name: 'statement.pdf', + content: 'Bank statement content 2026-09', + }) + const thread = { + getMessages: () => [{ getAttachments: () => [att] }], + } + const classification = { + canonicalDomain: '02_Finance_Legal', + subLabel: 'Finance/Banking', + } + const config = { canonicalDomains: ['02_Finance_Legal'] } + + // First run saves + const results1 = persistCanonicalAttachmentsToDrive( + thread, + classification, + config, + { DriveApp: driveApp } + ) + expect(results1).toHaveLength(1) + + // Second run detects duplicate and skips + const results2 = persistCanonicalAttachmentsToDrive( + thread, + classification, + config, + { DriveApp: driveApp } + ) + expect(results2).toHaveLength(0) + }) + + test('returns empty array when DriveApp is unavailable in services or global', () => { + const thread = { getMessages: () => [] } + const classification = { canonicalDomain: '01_Household' } + const results = persistCanonicalAttachmentsToDrive( + thread, + classification, + { canonicalDomains: ['01_Household'] }, + { DriveApp: null } + ) + expect(results).toEqual([]) + }) + + test('handles error when ensureDriveTaxonomyFolder throws', () => { + const thread = { getMessages: () => [] } + const classification = { canonicalDomain: '01_Household' } + const results = persistCanonicalAttachmentsToDrive( + thread, + classification, + { canonicalDomains: ['01_Household'] }, + { DriveApp: { getRootFolder: () => ({}) } } + ) + expect(results).toEqual([]) + }) + + test('catches and logs error when targetFolder.createFile throws', () => { + const att = createMockAttachment({ name: 'bill.pdf', size: 1000 }) + const thread = { + getMessages: () => [{ getAttachments: () => [att] }], + } + const driveApp = { + getRootFolder: () => ({ + getFoldersByName: () => ({ + hasNext: () => true, + next: () => ({ + getFoldersByName: () => ({ + hasNext: () => true, + next: () => ({ + getFilesByName: () => ({ hasNext: () => false }), + createFile: () => { + throw new Error('Disk quota exceeded') + }, + }), + }), + }), + }), + }), + } + const results = persistCanonicalAttachmentsToDrive( + thread, + { canonicalDomain: '01_Household' }, + { canonicalDomains: ['01_Household'] }, + { DriveApp: driveApp } + ) + expect(results).toEqual([]) + }) + }) + + describe('formatProgressiveDisclosureEntry with Attachments', () => { + test('renders clean markdown without attachments when none provided', () => { + const entry = formatProgressiveDisclosureEntry( + '2026-09-28', + 'Water Bill Notice', + 'billing@caw-al.gov', + 'Account Update', + 'Notice of water bill update.', + 'user@example.com' + ) + expect(entry).toContain('### 2026-09-28 — Water Bill Notice') + expect(entry).toContain('- **From**: billing@caw-al.gov') + expect(entry).not.toContain('**Attachments**:') + }) + + test('renders clickable Drive links when attachments are provided', () => { + const attachments = [ + { + name: 'Central_Alabama_Water_Bill.pdf', + url: 'https://drive.google.com/file/d/test-id-123/view', + }, + ] + const entry = formatProgressiveDisclosureEntry( + '2026-09-28', + 'Water Bill Notice', + 'billing@caw-al.gov', + 'Account Update', + 'Notice of water bill update.', + 'user@example.com', + attachments + ) + + expect(entry).toContain('- **Attachments**:\n') + expect(entry).toContain( + ' - [Central_Alabama_Water_Bill.pdf](https://drive.google.com/file/d/test-id-123/view)' + ) + }) + + test('renders plain attachment name when url is not provided', () => { + const attachments = [{ name: 'document_without_link.pdf' }] + const entry = formatProgressiveDisclosureEntry( + '2026-09-28', + 'Water Bill Notice', + 'billing@caw-al.gov', + 'Account Update', + 'Notice of water bill update.', + 'user@example.com', + attachments + ) + + expect(entry).toContain('- **Attachments**:\n') + expect(entry).toContain(' - document_without_link.pdf\n') + }) + }) + + describe('Immediate Metadata Description Tagging in persistCanonicalAttachmentsToDrive', () => { + test('tags created file with [AI_INDEXED], domain, and thread ID', () => { + const mockDriveApp = createMockDriveApp() + const att = createMockAttachment({ + name: 'Tuition_Invoice.pdf', + content: 'Invoice payload for school', + }) + const thread = { + getId: () => 'th-tuition-101', + getMessages: () => [{ getAttachments: () => [att] }], + } + const classification = { + canonicalDomain: '04_Family_Health', + subLabel: 'Family/School-Student', + } + const services = { + DriveApp: mockDriveApp, + Utilities: { + formatDate: () => '2026-09-30T12:00:00Z', + }, + } + + const saved = persistCanonicalAttachmentsToDrive( + thread, + classification, + {}, + services + ) + expect(saved).toHaveLength(1) + + const folder = mockDriveApp + .getRootFolder() + .getFoldersByName('04_Family_Health') + .next() + .getFoldersByName('Students') + .next() + const file = folder.getFilesByName('Tuition_Invoice.pdf').next() + expect(file.setDescription).toHaveBeenCalled() + const desc = file.getDescription() + expect(desc).toContain('[AI_INDEXED]') + expect(desc).toContain('Domain: 04_Family_Health') + expect(desc).toContain('Sub-label: Family/School-Student') + expect(desc).toContain('Thread-ID: th-tuition-101') + }) + + test('preserves .zip archive AND auto-unzips contents into dedicated subfolder', () => { + const mockDriveApp = createMockDriveApp() + const zipBlob = createMockAttachment({ + name: 'Onboarding_Docs.zip', + contentType: 'application/zip', + content: 'zip-binary-data', + size: 50000, + }) + const thread = { + getId: () => 'th-zip-101', + getFirstMessageSubject: () => 'Employment Onboarding Packet', + getMessages: () => [{ getAttachments: () => [zipBlob] }], + } + const classification = { + canonicalDomain: '06_Work_Career', + subLabel: 'Work/Career-Jobs', + } + const mockUnzippedEntries = [ + createMockAttachment({ + name: 'Offer_Letter.pdf', + contentType: 'application/pdf', + content: 'Offer details', + size: 20000, + }), + createMockAttachment({ + name: 'Benefits_Summary.docx', + contentType: + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + content: 'Benefits details', + size: 15000, + }), + createMockAttachment({ + name: '__MACOSX/._Offer_Letter.pdf', + contentType: 'application/octet-stream', + content: 'junk', + size: 500, + }), + createMockAttachment({ + name: '.DS_Store', + contentType: 'application/octet-stream', + content: 'junk', + size: 1000, + }), + ] + const services = { + DriveApp: mockDriveApp, + Utilities: { + formatDate: () => '2026-10-25T12:00:00Z', + unzip: jest.fn(() => mockUnzippedEntries), + }, + } + + const saved = persistCanonicalAttachmentsToDrive( + thread, + classification, + {}, + services + ) + + // 1 zip archive saved + 2 eligible unzipped files (junk ignored) = 3 total + expect(saved).toHaveLength(3) + expect(saved[0].name).toBe('Onboarding_Docs.zip') + expect(saved[1].name).toBe('Onboarding_Docs/Offer_Letter.pdf') + expect(saved[2].name).toBe('Onboarding_Docs/Benefits_Summary.docx') + + const careerFolder = mockDriveApp + .getRootFolder() + .getFoldersByName('06_Work_Career') + .next() + .getFoldersByName('Career_Jobs') + .next() + + // The original zip file is preserved in the parent folder + const zipFile = careerFolder.getFilesByName('Onboarding_Docs.zip').next() + expect(zipFile).toBeDefined() + expect(zipFile.getDescription()).toContain('[AI_INDEXED]') + + // The subfolder contains the extracted files + const zipSubfolder = careerFolder + .getFoldersByName('Onboarding_Docs') + .next() + expect(zipSubfolder).toBeDefined() + + const extractedPdf = zipSubfolder + .getFilesByName('Offer_Letter.pdf') + .next() + expect(extractedPdf).toBeDefined() + expect(extractedPdf.getDescription()).toContain( + 'Extracted-From: Onboarding_Docs.zip' + ) + expect(extractedPdf.getDescription()).toContain('Domain: 06_Work_Career') + + const extractedDoc = zipSubfolder + .getFilesByName('Benefits_Summary.docx') + .next() + expect(extractedDoc).toBeDefined() + + // Junk files were not created + expect(zipSubfolder.getFilesByName('.DS_Store').hasNext()).toBe(false) + }) + }) + + describe('extractZipArchiveToSubfolder', () => { + test('returns empty array if targetFolder is null or Utilities.unzip is missing', () => { + expect(extractZipArchiveToSubfolder(null, {})).toEqual([]) + const mockFolder = createMockFolder('target') + expect(extractZipArchiveToSubfolder(mockFolder, {}, {})).toEqual([]) + }) + + test('extracts eligible files into subfolder and skips OS artifacts', () => { + const parentFolder = createMockFolder('parent') + const mockZipBlob = createMockAttachment({ + name: 'archive_bundle.zip', + content: 'dummy-zip', + }) + const entries = [ + createMockAttachment({ + name: 'doc1.pdf', + content: 'pdf content', + size: 10000, + }), + createMockAttachment({ + name: '__MACOSX/._doc1.pdf', + content: 'junk', + size: 200, + }), + createMockAttachment({ + name: '.DS_Store', + content: 'junk', + size: 500, + }), + createMockAttachment({ + name: 'Thumbs.db', + content: 'junk', + size: 500, + }), + createMockAttachment({ + name: 'notes.txt', + content: 'text content', + size: 2000, + }), + ] + const services = { + Utilities: { + unzip: jest.fn(() => entries), + }, + } + const metadata = { + canonicalDomain: '01_Household', + subfolderName: 'Home_Inventory', + tagBlock: '[AI_INDEXED] 2026-10-25T12:00:00Z', + sourceFileName: 'archive_bundle.zip', + } + + const results = extractZipArchiveToSubfolder( + parentFolder, + mockZipBlob, + services, + metadata + ) + + expect(results).toHaveLength(2) + expect(results[0].name).toBe('archive_bundle/doc1.pdf') + expect(results[1].name).toBe('archive_bundle/notes.txt') + + const subfolder = parentFolder.getFoldersByName('archive_bundle').next() + expect(subfolder).toBeDefined() + const savedDoc = subfolder.getFilesByName('doc1.pdf').next() + expect(savedDoc.getDescription()).toContain( + 'Extracted-From: archive_bundle.zip' + ) + expect(savedDoc.getDescription()).toContain('[AI_INDEXED]') + }) + + test('skips duplicates if file already exists in extracted folder with same hash', () => { + const parentFolder = createMockFolder('parent') + const subfolder = parentFolder.createFolder('my_archive') + // Pre-populate with identical file + const existingAtt = createMockAttachment({ + name: 'already_there.pdf', + content: 'exact same content', + size: 5000, + }) + subfolder.createFile(existingAtt) + + const mockZipBlob = createMockAttachment({ + name: 'my_archive.zip', + content: 'dummy-zip', + }) + const entries = [ + createMockAttachment({ + name: 'already_there.pdf', + content: 'exact same content', + size: 5000, + }), + createMockAttachment({ + name: 'new_file.pdf', + content: 'different content', + size: 6000, + }), + ] + const services = { + Utilities: { + unzip: jest.fn(() => entries), + }, + } + + const results = extractZipArchiveToSubfolder( + parentFolder, + mockZipBlob, + services, + { sourceFileName: 'my_archive.zip' } + ) + + expect(results).toHaveLength(1) + expect(results[0].name).toBe('my_archive/new_file.pdf') + }) + + test('handles Drive File object with getBlob() and getName()', () => { + const parentFolder = createMockFolder('parent') + const mockBlob = createMockAttachment({ + name: 'drive_file.zip', + content: 'raw-zip', + }) + const mockDriveFile = { + getName: () => 'drive_file.zip', + getBlob: () => mockBlob, + } + const entries = [ + createMockAttachment({ + name: 'statement.pdf', + content: 'statement content', + size: 10000, + }), + ] + const services = { + Utilities: { + unzip: jest.fn(() => entries), + }, + } + + const results = extractZipArchiveToSubfolder( + parentFolder, + mockDriveFile, + services + ) + + expect(results).toHaveLength(1) + expect(results[0].name).toBe('drive_file/statement.pdf') + }) + + test('resolves filename collisions for non-duplicate zip entries with identical basenames and logs truncation warning past 25 entries', () => { + const parentFolder = createMockFolder('parent') + const mockZipBlob = createMockAttachment({ + name: 'collision_test.zip', + content: 'zip', + }) + const entries = [ + createMockAttachment({ + name: 'dir1/report.pdf', + content: 'report version A', + size: 5000, + }), + createMockAttachment({ + name: 'dir2/report.pdf', + content: 'report version B - different', + size: 5500, + }), + ] + for (let i = 0; i < 25; i++) { + entries.push( + createMockAttachment({ + name: `extra_${i}.pdf`, + content: `extra content ${i}`, + size: 4000 + i, + }) + ) + } + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const results = extractZipArchiveToSubfolder( + parentFolder, + mockZipBlob, + { + Utilities: { + unzip: jest.fn(() => entries), + formatDate: () => '_120000123', + }, + Session: { + getScriptTimeZone: () => 'GMT', + }, + } + ) + expect(results).toHaveLength(25) + expect(results[0].name).toBe('collision_test/report.pdf') + expect(results[1].name).toBe('collision_test/report_120000123.pdf') + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('truncating extraction') + ) + } finally { + warnSpy.mockRestore() + } + }) + }) + + describe('unzipExistingDriveArchive', () => { + test('extracts existing archive by filename in targetFolder', () => { + const parentFolder = createMockFolder('parent') + const mockZipBlob = createMockAttachment({ + name: 'existing_packet.zip', + content: 'zip-data', + }) + parentFolder.createFile(mockZipBlob) + + const entries = [ + createMockAttachment({ + name: 'tax_return.pdf', + content: 'tax data', + size: 12000, + }), + ] + const services = { + Utilities: { + unzip: jest.fn(() => entries), + }, + } + + const results = unzipExistingDriveArchive( + 'existing_packet.zip', + parentFolder, + services + ) + + expect(results).toHaveLength(1) + expect(results[0].name).toBe('existing_packet/tax_return.pdf') + const sub = parentFolder.getFoldersByName('existing_packet').next() + expect(sub.getFilesByName('tax_return.pdf').hasNext()).toBe(true) + }) + + test('extracts archive by fileId using DriveApp.getFileById', () => { + const parentFolder = createMockFolder('parent') + const mockZipBlob = createMockAttachment({ + name: 'remote_packet.zip', + content: 'zip-data', + }) + const zipFile = parentFolder.createFile(mockZipBlob) + const fileId = zipFile.getId() + + const mockDriveApp = { + getFileById: jest.fn((id) => { + if (id === fileId) { + return { + getName: () => 'remote_packet.zip', + getBlob: () => mockZipBlob, + getParents: () => ({ + hasNext: () => true, + next: () => parentFolder, + }), + } + } + return null + }), + } + + const entries = [ + createMockAttachment({ + name: 'closing_statement.pdf', + content: 'closing data', + size: 25000, + }), + ] + const services = { + DriveApp: mockDriveApp, + Utilities: { + unzip: jest.fn(() => entries), + }, + } + + const results = unzipExistingDriveArchive(fileId, null, services) + + expect(results).toHaveLength(1) + expect(results[0].name).toBe('remote_packet/closing_statement.pdf') + }) + + test('returns empty array if file cannot be found', () => { + const parentFolder = createMockFolder('parent') + const mockDriveApp = { + getFileById: jest.fn(() => null), + } + const results = unzipExistingDriveArchive('missing.zip', parentFolder, { + DriveApp: mockDriveApp, + }) + expect(results).toEqual([]) + }) + }) + + describe('Attachment Verification in auditClassifications', () => { + test('flags MISSING_DRIVE_ATTACHMENT when attachment is not in Drive', () => { + const mockDriveApp = createMockDriveApp() + const att = createMockAttachment({ + name: 'Missing_Receipt.pdf', + content: 'Receipt content', + }) + const thread = { + getId: () => 'th-receipt-1', + getFirstMessageSubject: () => 'Order Receipt', + sender: 'orders@vendor.com', + labels: ['02_Finance_Legal', 'Finance/Purchases'], + getMessages: () => [{ getAttachments: () => [att] }], + } + + const report = auditClassifications([thread], { + driveApp: mockDriveApp, + }) + + expect(report.flaggedCount).toBe(1) + expect(report.findings[0].flags).toEqual( + expect.arrayContaining([ + expect.stringContaining('MISSING_DRIVE_ATTACHMENT'), + ]) + ) + }) + + test('flags UNTAGGED_DRIVE_ATTACHMENT when file exists but lacks [AI_INDEXED]', () => { + const mockDriveApp = createMockDriveApp() + const targetFolder = ensureDriveTaxonomyFolder( + '02_Finance_Legal', + 'Purchases', + mockDriveApp + ) + // Create untagged file in folder + targetFolder.createFile({ + getName: () => 'Untagged_Doc.pdf', + getBytes: () => Buffer.from('Doc content', 'utf-8'), + }) + + const att = createMockAttachment({ + name: 'Untagged_Doc.pdf', + content: 'Doc content', + }) + const thread = { + getId: () => 'th-untagged-1', + getFirstMessageSubject: () => 'Purchased Item', + sender: 'orders@vendor.com', + labels: ['02_Finance_Legal', 'Finance/Purchases'], + getMessages: () => [{ getAttachments: () => [att] }], + } + + const report = auditClassifications([thread], { + driveApp: mockDriveApp, + }) + + expect(report.flaggedCount).toBe(1) + expect(report.findings[0].flags).toEqual( + expect.arrayContaining([ + expect.stringContaining('UNTAGGED_DRIVE_ATTACHMENT'), + ]) + ) + }) + + test('passes without flags when file is stored and properly tagged', () => { + const mockDriveApp = createMockDriveApp() + const targetFolder = ensureDriveTaxonomyFolder( + '02_Finance_Legal', + 'Purchases', + mockDriveApp + ) + const file = targetFolder.createFile({ + getName: () => 'Tagged_Doc.pdf', + getBytes: () => Buffer.from('Doc content', 'utf-8'), + }) + file.setDescription('[AI_INDEXED]\nDomain: 02_Finance_Legal') + + const att = createMockAttachment({ + name: 'Tagged_Doc.pdf', + content: 'Doc content', + }) + const thread = { + getId: () => 'th-tagged-1', + getFirstMessageSubject: () => 'Order Confirmation', + sender: 'orders@vendor.com', + labels: ['02_Finance_Legal', 'Finance/Purchases'], + getMessages: () => [{ getAttachments: () => [att] }], + } + + const report = auditClassifications([thread], { + driveApp: mockDriveApp, + }) + + expect(report.flaggedCount).toBe(0) + }) + + test('correctly identifies folder when label is compound domain-prefixed (02_Finance_Legal/Purchases)', () => { + const mockDriveApp = createMockDriveApp() + const targetFolder = ensureDriveTaxonomyFolder( + '02_Finance_Legal', + 'Purchases', + mockDriveApp + ) + const file = targetFolder.createFile({ + getName: () => 'Tagged_Receipt.pdf', + getBytes: () => Buffer.from('Receipt content', 'utf-8'), + }) + file.setDescription('[AI_INDEXED] Tagged description') + + const att = createMockAttachment({ + name: 'Tagged_Receipt.pdf', + content: 'Receipt content', + }) + const thread = { + getId: () => 'th-compound-1', + getFirstMessageSubject: () => 'Order Receipt', + sender: 'orders@vendor.com', + labels: ['02_Finance_Legal/Purchases'], + getMessages: () => [{ getAttachments: () => [att] }], + } + + const report = auditClassifications([thread], { + driveApp: mockDriveApp, + }) + + expect(report.flaggedCount).toBe(0) + }) + }) + + describe('auditAndBackfillCanonicalAttachments', () => { + test('dryRun mode reports missing, untagged, and stored attachments without mutating Drive', () => { + const mockDriveApp = createMockDriveApp() + const folder = ensureDriveTaxonomyFolder( + '01_Household', + 'Primary_House', + mockDriveApp + ) + // File 1: Already stored and tagged + const file1 = folder.createFile({ + getName: () => 'Plan_Approved.pdf', + getBytes: () => Buffer.from('Plan bytes', 'utf-8'), + }) + file1.setDescription('[AI_INDEXED]\nDomain: 01_Household') + + // File 2: Already stored but untagged + folder.createFile({ + getName: () => 'Untagged_Spec.pdf', + getBytes: () => Buffer.from('Spec bytes', 'utf-8'), + }) + + const att1 = createMockAttachment({ + name: 'Plan_Approved.pdf', + content: 'Plan bytes', + }) + const att2 = createMockAttachment({ + name: 'Untagged_Spec.pdf', + content: 'Spec bytes', + }) + const att3 = createMockAttachment({ + name: 'Missing_Estimate.pdf', + content: 'Estimate bytes', + }) + + const thread = { + getId: () => 'th-household-100', + getFirstMessageSubject: () => 'Porch Construction Plans', + labels: ['01_Household', 'Household/Property'], + getMessages: () => [ + { getAttachments: () => [att1, att2] }, + { getAttachments: () => [att3] }, + ], + } + + const mockGmailApp = { + search: jest.fn(() => [thread]), + } + + const report = auditAndBackfillCanonicalAttachments( + { dryRun: true }, + {}, + { + GmailApp: mockGmailApp, + DriveApp: mockDriveApp, + } + ) + + expect(report.scannedThreads).toBe(1) + expect(report.threadsWithEligibleAttachments).toBe(1) + expect(report.totalEligibleAttachments).toBe(3) + expect(report.alreadyStoredAndTagged).toBe(1) + expect(report.alreadyStoredUntagged).toBe(1) + expect(report.missingFromDrive).toBe(1) + expect(report.backfilledCount).toBe(0) + expect(report.taggedCount).toBe(0) + expect(report.dryRun).toBe(true) + + // Ensure missing file was NOT created in dryRun mode + expect(folder.getFilesByName('Missing_Estimate.pdf').hasNext()).toBe( + false + ) + }) + + test('backfill mode creates missing files, tags them, and updates untagged files', () => { + const mockDriveApp = createMockDriveApp() + const folder = ensureDriveTaxonomyFolder( + '02_Finance_Legal', + 'Banking', + mockDriveApp + ) + + // File 1: Stored but untagged + const file1 = folder.createFile({ + getName: () => 'Old_Statement.pdf', + getBytes: () => Buffer.from('Statement 1 bytes', 'utf-8'), + }) + + const att1 = createMockAttachment({ + name: 'Old_Statement.pdf', + content: 'Statement 1 bytes', + }) + const att2 = createMockAttachment({ + name: 'New_Statement.pdf', + content: 'Statement 2 bytes', + }) + + const thread = { + getId: () => 'th-bank-200', + getFirstMessageSubject: () => 'Monthly Statement', + labels: ['02_Finance_Legal', 'Finance/Banking'], + getMessages: () => [{ getAttachments: () => [att1, att2] }], + } + + const mockGmailApp = { + search: jest.fn(() => [thread]), + } + + const services = { + GmailApp: mockGmailApp, + DriveApp: mockDriveApp, + Utilities: { + formatDate: () => '2026-09-30T18:00:00Z', + }, + } + + const report = auditAndBackfillCanonicalAttachments( + { dryRun: false }, + {}, + services + ) + + expect(report.scannedThreads).toBe(1) + expect(report.totalEligibleAttachments).toBe(2) + expect(report.alreadyStoredUntagged).toBe(1) + expect(report.missingFromDrive).toBe(1) + expect(report.backfilledCount).toBe(1) + expect(report.taggedCount).toBe(1) + expect(report.dryRun).toBe(false) + + // Verify missing file was created + const newFile = folder.getFilesByName('New_Statement.pdf').next() + expect(newFile).toBeDefined() + expect(newFile.getDescription()).toContain('[AI_INDEXED]') + expect(newFile.getDescription()).toContain('Domain: 02_Finance_Legal') + + // Verify untagged file was updated + expect(file1.getDescription()).toContain('[AI_INDEXED]') + expect(file1.getDescription()).toContain('Domain: 02_Finance_Legal') + }) + + test('runDryRunAttachmentAudit defaults to dryRun: true', () => { + const mockDriveApp = createMockDriveApp() + const mockGmailApp = { search: jest.fn(() => []) } + const report = runDryRunAttachmentAudit( + {}, + {}, + { GmailApp: mockGmailApp, DriveApp: mockDriveApp } + ) + expect(report.dryRun).toBe(true) + expect(report.scannedThreads).toBe(0) + }) + + test('runLiveAttachmentBackfill defaults to dryRun: false', () => { + const mockDriveApp = createMockDriveApp() + const mockGmailApp = { search: jest.fn(() => []) } + const report = runLiveAttachmentBackfill( + {}, + {}, + { GmailApp: mockGmailApp, DriveApp: mockDriveApp } + ) + expect(report.dryRun).toBe(false) + expect(report.scannedThreads).toBe(0) + }) + + test('auditAndBackfillCanonicalAttachments resolves domain when thread carries only canonical sub-labels', () => { + const mockDriveApp = createMockDriveApp() + const folder = ensureDriveTaxonomyFolder( + '01_Household', + 'Primary_House', + mockDriveApp + ) + + const att = createMockAttachment({ + name: 'Home_Invoice.pdf', + content: 'Invoice content payload', + }) + + const thread = { + getId: () => 'th-sublabel-only-1', + getFirstMessageSubject: () => 'Contractor Invoice', + labels: ['Household/Property'], // NO root "01_Household" label! + getMessages: () => [{ getAttachments: () => [att] }], + } + + const mockGmailApp = { + search: jest.fn(() => [thread]), + } + + const services = { + GmailApp: mockGmailApp, + DriveApp: mockDriveApp, + Utilities: { formatDate: () => '2026-10-05T12:00:00Z' }, + } + + const report = auditAndBackfillCanonicalAttachments( + { dryRun: false }, + {}, + services + ) + + expect(report.scannedThreads).toBe(1) + expect(report.threadsWithEligibleAttachments).toBe(1) + expect(report.totalEligibleAttachments).toBe(1) + expect(report.backfilledCount).toBe(1) + expect(report.taggedCount).toBe(0) + + const savedFile = folder.getFilesByName('Home_Invoice.pdf').next() + expect(savedFile).toBeDefined() + expect(savedFile.getDescription()).toContain('Domain: 01_Household') + expect(savedFile.getDescription()).toContain( + 'Sub-label: Household/Property' + ) + }) + + test('defaults to canonical domain label search query when opts.query is omitted', () => { + const mockDriveApp = createMockDriveApp() + const mockGmailApp = { + search: jest.fn(() => []), + } + + auditAndBackfillCanonicalAttachments( + {}, + {}, + { GmailApp: mockGmailApp, DriveApp: mockDriveApp } + ) + + expect(mockGmailApp.search).toHaveBeenCalledWith( + expect.stringContaining( + 'has:attachment (label:01_Household OR label:02_Finance_Legal' + ), + 0, + 50 + ) + }) + + test('dryRun mode does not create missing taxonomy folders in Google Drive', () => { + const mockDriveApp = createMockDriveApp() + const att = createMockAttachment({ + name: 'Unbacked_Bill.pdf', + content: 'Bill content', + }) + const thread = { + getId: () => 'th-dry-nofolder', + getFirstMessageSubject: () => 'Water Bill', + labels: ['01_Household', 'Household/Property'], + getMessages: () => [{ getAttachments: () => [att] }], + } + const mockGmailApp = { + search: jest.fn(() => [thread]), + } + + const report = auditAndBackfillCanonicalAttachments( + { dryRun: true }, + {}, + { GmailApp: mockGmailApp, DriveApp: mockDriveApp } + ) + + expect(report.missingFromDrive).toBe(1) + expect( + mockDriveApp.getRootFolder().getFoldersByName('01_Household').hasNext() + ).toBe(false) + }) + + test('supports optional rootFolderName and LockService in ensureDriveTaxonomyFolder and identifies matching duplicate among multiple same-name files', () => { + const mockDriveApp = createMockDriveApp() + const mockLock = { + tryLock: jest.fn(() => true), + releaseLock: jest.fn(), + } + const mockLockService = { + getScriptLock: jest.fn(() => mockLock), + } + + const folder = ensureDriveTaxonomyFolder( + '02_Finance_Legal', + 'Banking', + mockDriveApp, + { + rootFolderName: 'Self_Vault', + lockService: mockLockService, + } + ) + expect(mockLockService.getScriptLock).toHaveBeenCalled() + expect(mockLock.releaseLock).toHaveBeenCalled() + expect( + mockDriveApp.getRootFolder().getFoldersByName('Self_Vault').hasNext() + ).toBe(true) + + // Create two files with the same name in folder; only the second matches att content + const firstFile = folder.createFile({ + getName: () => 'Duplicate_Name.pdf', + getBytes: () => Buffer.from('Other file content', 'utf-8'), + }) + firstFile.setDescription('[AI_INDEXED] Other') + + const secondFile = folder.createFile({ + getName: () => 'Duplicate_Name.pdf', + getBytes: () => Buffer.from('Target file content', 'utf-8'), + }) + // Leave secondFile untagged to verify findMatchingDuplicateFile_ selects secondFile, not firstFile! + + const att = createMockAttachment({ + name: 'Duplicate_Name.pdf', + content: 'Target file content', + }) + const thread = { + getId: () => 'th-multi-dup', + getFirstMessageSubject: () => 'Statement', + labels: ['02_Finance_Legal', 'Finance/Banking'], + getMessages: () => [{ getAttachments: () => [att] }], + } + const mockGmailApp = { + search: jest.fn(() => [thread]), + } + + const report = auditAndBackfillCanonicalAttachments( + { dryRun: false }, + { driveRootFolderName: 'Self_Vault' }, + { + GmailApp: mockGmailApp, + DriveApp: mockDriveApp, + LockService: mockLockService, + } + ) + + expect(report.alreadyStoredUntagged).toBe(1) + expect(report.taggedCount).toBe(1) + expect(secondFile.getDescription()).toContain('[AI_INDEXED]') + }) + }) + + describe('resolveCanonicalDomainFromLabels', () => { + test('resolves direct root domain codes', () => { + expect(resolveCanonicalDomainFromLabels(['01_Household'])).toBe( + '01_Household' + ) + expect(resolveCanonicalDomainFromLabels(['02_Finance_Legal'])).toBe( + '02_Finance_Legal' + ) + }) + + test('resolves canonical domain from sub-label prefixes', () => { + expect(resolveCanonicalDomainFromLabels(['Household/General'])).toBe( + '01_Household' + ) + expect(resolveCanonicalDomainFromLabels(['Finance/Banking'])).toBe( + '02_Finance_Legal' + ) + expect(resolveCanonicalDomainFromLabels(['Vehicles/Maintenance'])).toBe( + '03_Vehicles' + ) + expect(resolveCanonicalDomainFromLabels(['Family/School-Student'])).toBe( + '04_Family_Health' + ) + expect(resolveCanonicalDomainFromLabels(['Tech/Alerts-Monitoring'])).toBe( + '05_Tech_Infrastructure' + ) + expect(resolveCanonicalDomainFromLabels(['Work/General'])).toBe( + '06_Work_Career' + ) + expect(resolveCanonicalDomainFromLabels(['Community/NonProfit'])).toBe( + '07_Community_NonProfit' + ) + expect(resolveCanonicalDomainFromLabels(['Projects/Telemetry'])).toBe( + '07_Community_NonProfit' + ) + }) + + test('returns null for non-canonical or empty labels', () => { + expect(resolveCanonicalDomainFromLabels([])).toBeNull() + expect(resolveCanonicalDomainFromLabels(null)).toBeNull() + expect(resolveCanonicalDomainFromLabels(['INBOX', 'UNREAD'])).toBeNull() + expect(resolveCanonicalDomainFromLabels(['Promotions/Sale'])).toBeNull() + }) + }) +}) diff --git a/src/gmail-ai-classifier/tests/gitHubSync.test.js b/src/gmail-ai-classifier/tests/gitHubSync.test.js deleted file mode 100644 index fe595f8f..00000000 --- a/src/gmail-ai-classifier/tests/gitHubSync.test.js +++ /dev/null @@ -1,1093 +0,0 @@ -const { - appendMarkdownEntryToGitHubRepo, - executeGitHubCommit, - extractTopicTitleFromPath, - insertEntryIntoLogSection, - fetchRulesFromGitHub, - commitRulesToGitHub, - syncTwoWayRules, - rulesContentEquals_, - isSafeToOverwriteRemote_, -} = require('../src/github-sync.js') - -describe('gitHubSync Module', () => { - beforeEach(() => { - jest.clearAllMocks() - global.GITHUB_REPO_OWNER = 'user-org' - global.GITHUB_REPO_NAME = 'self-private' - global.PropertiesService = { - getScriptProperties: jest.fn(() => ({ - getProperty: jest.fn((key) => { - if (key === 'GITHUB_PAT') return 'mock-pat-123' - if (key === 'CLASSIFICATION_RULES_JSON') - return JSON.stringify({ - version: '1.0.0', - updatedAt: '2026-08-08T12:00:00Z', - rules: [], - }) - return null - }), - setProperty: jest.fn(), - })), - } - global.Utilities = { - base64Decode: jest.fn((str) => - Buffer.from(str, 'base64').toString('binary') - ), - base64Encode: jest.fn((str) => Buffer.from(str).toString('base64')), - newBlob: jest.fn((data) => ({ getDataAsString: () => data })), - formatDate: jest.fn(() => '2026-08-08T14:00:00Z'), - sleep: jest.fn(), - } - }) - - describe('extractTopicTitleFromPath', () => { - test('extracts title from multi-level path', () => { - expect(extractTopicTitleFromPath('01_Household/my-topic/index.md')).toBe( - 'My Topic' - ) - }) - test('extracts title from single-level path', () => { - expect(extractTopicTitleFromPath('my-topic.md')).toBe('My Topic.Md') - }) - }) - - describe('insertEntryIntoLogSection', () => { - test('inserts before detailsMarker if present', () => { - const content = - '# Header\n<details open><summary>Log</summary>\n</details>' - const result = insertEntryIntoLogSection(content, '- New Log Entry') - expect(result).toContain('- New Log Entry\n</details>') - }) - - test('inserts after section3Marker if detailsMarker not present', () => { - const content = '# Header\n## 3. Ingested Activity\nExisting logs' - const result = insertEntryIntoLogSection(content, '- New Log Entry') - expect(result).toContain('## 3. Ingested Activity\n- New Log Entry\n') - }) - - test('appends to end if markers not present', () => { - const content = '# Header\nSome body text' - const result = insertEntryIntoLogSection(content, '- New Log Entry') - expect(result).toBe('# Header\nSome body text\n- New Log Entry') - }) - }) - - describe('executeGitHubCommit', () => { - test('handles 404 by auto-creating new note', () => { - global.UrlFetchApp = { - fetch: jest - .fn() - .mockImplementationOnce(() => ({ - getResponseCode: () => 404, - getContentText: () => 'Not Found', - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 201, - getContentText: () => JSON.stringify({ content: {} }), - })), - } - - const res = executeGitHubCommit( - '01_Household/test/index.md', - '- entry', - 'commit msg', - 'pat-123' - ) - expect(res).toBe(true) - }) - - test('handles 200 with idempotent skip (returns boolean true)', () => { - const existingContent = '- entry' - const base64 = Buffer.from(existingContent).toString('base64') - const putSpy = jest.fn(() => ({ - getResponseCode: () => 200, - getContentText: () => JSON.stringify({ content: base64, sha: 'sha-1' }), - })) - global.UrlFetchApp = { fetch: putSpy } - - const res = executeGitHubCommit( - '01_Household/test/index.md', - '- entry', - 'commit msg', - 'pat-123' - ) - // Idempotent skip is a success: returns boolean true (unified return type), - // and performs no PUT (only the initial GET). - expect(res).toBe(true) - expect(putSpy).toHaveBeenCalledTimes(1) - }) - - test('handles 500 error fetching file', () => { - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 500, - getContentText: () => 'Internal Error', - })), - } - - const res = executeGitHubCommit( - '01_Household/test/index.md', - '- entry', - 'commit msg', - 'pat-123' - ) - expect(res).toBe(false) - }) - - test('handles 409 SHA collision on PUT', () => { - const base64 = Buffer.from('- old entry').toString('base64') - global.UrlFetchApp = { - fetch: jest - .fn() - .mockImplementationOnce(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: base64, sha: 'sha-1' }), - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 409, - getContentText: () => 'Conflict', - })), - } - - const res = executeGitHubCommit( - '01_Household/test/index.md', - '- new entry', - 'commit msg', - 'pat-123' - ) - expect(res).toBe(false) - }) - - test('handles 500 error committing file PUT', () => { - const base64 = Buffer.from('- old entry').toString('base64') - global.UrlFetchApp = { - fetch: jest - .fn() - .mockImplementationOnce(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: base64, sha: 'sha-1' }), - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 500, - getContentText: () => 'Error PUT', - })), - } - - const res = executeGitHubCommit( - '01_Household/test/index.md', - '- new entry', - 'commit msg', - 'pat-123' - ) - expect(res).toBe(false) - }) - - test('handles exception during fetch', () => { - global.UrlFetchApp = { - fetch: jest.fn(() => { - throw new Error('Network error') - }), - } - - const res = executeGitHubCommit( - '01_Household/test/index.md', - '- new entry', - 'commit msg', - 'pat-123' - ) - expect(res).toBe(false) - }) - }) - - describe('appendMarkdownEntryToGitHubRepo', () => { - test('returns false when GITHUB_PAT is missing', () => { - global.PropertiesService = { - getScriptProperties: () => ({ getProperty: () => null }), - } - expect(appendMarkdownEntryToGitHubRepo('path.md', '- entry')).toBe(false) - }) - - test('retries on temporary failure and succeeds', () => { - const base64 = Buffer.from('- old entry').toString('base64') - global.UrlFetchApp = { - fetch: jest - .fn() - .mockImplementationOnce(() => ({ - getResponseCode: () => 500, - getContentText: () => 'Err', - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: base64, sha: 'sha-1' }), - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 200, - getContentText: () => '{}', - })), - } - - const result = appendMarkdownEntryToGitHubRepo('path.md', '- entry') - expect(result).toBe(true) - }) - - test('fails after maximum retries', () => { - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 500, - getContentText: () => 'Err', - })), - } - expect(appendMarkdownEntryToGitHubRepo('path.md', '- entry')).toBe(false) - }) - }) - - describe('2-Way Rules Sync Engine', () => { - test('fetchRulesFromGitHub returns parsed rules object with SHA', () => { - const mockRules = { - version: '1.0.0', - updatedAt: '2026-08-08T14:00:00Z', - canonicalDomains: [], - } - const mockBase64 = Buffer.from(JSON.stringify(mockRules)).toString( - 'base64' - ) - - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: mockBase64, sha: 'mock-sha-abc' }), - })), - } - - const res = fetchRulesFromGitHub('rules.json', 'mock-token') - expect(res._sha).toBe('mock-sha-abc') - }) - - test('fetchRulesFromGitHub returns null when GITHUB_PAT missing', () => { - global.PropertiesService = { - getScriptProperties: () => ({ getProperty: () => null }), - } - expect(fetchRulesFromGitHub()).toBeNull() - }) - - test('fetchRulesFromGitHub returns null on 404', () => { - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 404, - getContentText: () => 'Not Found', - })), - } - expect(fetchRulesFromGitHub('rules.json', 'mock-token')).toBeNull() - }) - - test('fetchRulesFromGitHub throws on non-404 HTTP error', () => { - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 500, - getContentText: () => 'Internal Server Error', - })), - } - expect(() => fetchRulesFromGitHub('rules.json', 'mock-token')).toThrow( - 'GitHub API returned status 500' - ) - }) - - test('fetchRulesFromGitHub re-throws on network exception', () => { - global.UrlFetchApp = { - fetch: jest.fn(() => { - throw new Error('API Exception') - }), - } - expect(() => fetchRulesFromGitHub('rules.json', 'mock-token')).toThrow( - 'API Exception' - ) - }) - - test('commitRulesToGitHub handles missing token', () => { - global.PropertiesService = { - getScriptProperties: () => ({ getProperty: () => null }), - } - expect(commitRulesToGitHub({}, 'msg')).toBe(false) - }) - - test('commitRulesToGitHub handles API exception', () => { - global.UrlFetchApp = { - fetch: jest.fn(() => { - throw new Error('API Error') - }), - } - expect(commitRulesToGitHub({}, 'msg', 'token')).toBe(false) - }) - - test('commitRulesToGitHub uses knownSha and skips GET', () => { - const putSpy = jest.fn(() => ({ - getResponseCode: () => 201, - getContentText: () => '{}', - })) - global.UrlFetchApp = { fetch: putSpy } - const result = commitRulesToGitHub({}, 'msg', 'token', 'known-sha-123') - expect(result).toBe(true) - // Only one fetch call (PUT) — no GET to re-fetch SHA - expect(putSpy).toHaveBeenCalledTimes(1) - const payload = JSON.parse(putSpy.mock.calls[0][1].payload) - expect(payload.sha).toBe('known-sha-123') - }) - - test('commitRulesToGitHub retries once on 409 SHA conflict', () => { - const base64 = Buffer.from( - JSON.stringify({ updatedAt: '2026-08-08T10:00:00Z' }) - ).toString('base64') - const fetchSpy = jest - .fn() - // 1st call: initial PUT → 409 - .mockImplementationOnce(() => ({ - getResponseCode: () => 409, - getContentText: () => 'Conflict', - })) - // 2nd call: GET to refresh SHA - .mockImplementationOnce(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: base64, sha: 'fresh-sha' }), - })) - // 3rd call: retry PUT → 201 - .mockImplementationOnce(() => ({ - getResponseCode: () => 201, - getContentText: () => '{}', - })) - global.UrlFetchApp = { fetch: fetchSpy } - const result = commitRulesToGitHub({}, 'msg', 'token', 'stale-sha') - expect(result).toBe(true) - expect(fetchSpy).toHaveBeenCalledTimes(3) - const retryPayload = JSON.parse(fetchSpy.mock.calls[2][1].payload) - expect(retryPayload.sha).toBe('fresh-sha') - }) - - test('syncTwoWayRules initializes local ScriptProperty when empty', () => { - const remoteRules = { - version: '1.0.0', - updatedAt: '2026-08-08T10:00:00Z', - } - const mockBase64 = Buffer.from(JSON.stringify(remoteRules)).toString( - 'base64' - ) - - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: mockBase64, sha: 'sha-1' }), - })), - } - - const setPropertySpy = jest.fn() - global.PropertiesService = { - getScriptProperties: () => ({ - getProperty: (key) => { - if (key === 'GITHUB_PAT') return 'mock-pat' - return null - }, - setProperty: setPropertySpy, - }), - } - - expect(syncTwoWayRules()).toBe(true) - expect(setPropertySpy).toHaveBeenCalledWith( - 'CLASSIFICATION_RULES_JSON', - expect.stringContaining('1.0.0') - ) - }) - - test('syncTwoWayRules handles local rule push when local is newer', () => { - const remoteRules = { - version: '1.0.0', - updatedAt: '2026-08-08T10:00:00Z', - } - const mockBase64 = Buffer.from(JSON.stringify(remoteRules)).toString( - 'base64' - ) - - // syncTwoWayRules passes remoteRules._sha to commitRulesToGitHub, - // so only 2 fetches: GET (sync read) + PUT (commit). - const fetchSpy = jest - .fn() - .mockImplementationOnce(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: mockBase64, sha: 'sha-old' }), - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 201, - getContentText: () => '{}', - })) - - global.UrlFetchApp = { fetch: fetchSpy } - global.PropertiesService = { - getScriptProperties: () => ({ - getProperty: (key) => { - if (key === 'GITHUB_PAT') return 'mock-pat' - if (key === 'CLASSIFICATION_RULES_JSON') - return JSON.stringify({ - version: '1.1.0', - updatedAt: '2026-08-08T15:00:00Z', - }) - return null - }, - setProperty: jest.fn(), - }), - } - - expect(syncTwoWayRules()).toBe(true) - expect(fetchSpy).toHaveBeenCalledTimes(2) - }) - - test('syncTwoWayRules returns false on malformed local rules JSON', () => { - const remoteRules = { - version: '1.0.0', - updatedAt: '2026-08-08T10:00:00Z', - } - const mockBase64 = Buffer.from(JSON.stringify(remoteRules)).toString( - 'base64' - ) - - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: mockBase64, sha: 'sha-1' }), - })), - } - global.PropertiesService = { - getScriptProperties: () => ({ - getProperty: (key) => { - if (key === 'GITHUB_PAT') return 'mock-pat' - if (key === 'CLASSIFICATION_RULES_JSON') return '{invalid json' - return null - }, - setProperty: jest.fn(), - }), - } - - expect(syncTwoWayRules()).toBe(false) - }) - - test('syncTwoWayRules logs up-to-date when timestamps match', () => { - const rules = { version: '1.0.0', updatedAt: '2026-08-08T10:00:00Z' } - const mockBase64 = Buffer.from(JSON.stringify(rules)).toString('base64') - - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: mockBase64, sha: 'sha-1' }), - })), - } - - global.PropertiesService = { - getScriptProperties: () => ({ - getProperty: (key) => { - if (key === 'GITHUB_PAT') return 'mock-pat' - if (key === 'CLASSIFICATION_RULES_JSON') - return JSON.stringify(rules) - return null - }, - setProperty: jest.fn(), - }), - } - - expect(syncTwoWayRules()).toBe(true) - }) - - test('syncTwoWayRules returns false when remoteRules is null', () => { - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 404, - getContentText: () => 'Not Found', - })), - } - - global.PropertiesService = { - getScriptProperties: () => ({ - getProperty: () => 'mock-pat', - }), - } - - expect(syncTwoWayRules()).toBe(false) - }) - - test('syncTwoWayRules returns false when fetchRulesFromGitHub throws', () => { - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 500, - getContentText: () => 'Internal Server Error', - })), - } - expect(syncTwoWayRules()).toBe(false) - }) - - test('syncTwoWayRules pulls remote when remote is newer', () => { - const remoteRules = { - version: '1.1.0', - updatedAt: '2026-08-09T10:00:00Z', - } - const mockBase64 = Buffer.from(JSON.stringify(remoteRules)).toString( - 'base64' - ) - - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: mockBase64, sha: 'sha-1' }), - })), - } - const setPropertySpy = jest.fn() - global.PropertiesService = { - getScriptProperties: () => ({ - getProperty: (key) => { - if (key === 'GITHUB_PAT') return 'mock-pat' - if (key === 'CLASSIFICATION_RULES_JSON') - return JSON.stringify({ - version: '1.0.0', - updatedAt: '2026-08-08T10:00:00Z', - }) - return null - }, - setProperty: setPropertySpy, - }), - } - - expect(syncTwoWayRules()).toBe(true) - expect(setPropertySpy).toHaveBeenCalledWith( - 'CLASSIFICATION_RULES_JSON', - expect.stringContaining('1.1.0') - ) - }) - - test('syncTwoWayRules pulls remote when timestamps match but content differs', () => { - const remoteRules = { - version: '1.0.0', - updatedAt: '2026-08-08T10:00:00Z', - extra: 'remote-only-field', - } - const mockBase64 = Buffer.from(JSON.stringify(remoteRules)).toString( - 'base64' - ) - - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: mockBase64, sha: 'sha-1' }), - })), - } - const setPropertySpy = jest.fn() - global.PropertiesService = { - getScriptProperties: () => ({ - getProperty: (key) => { - if (key === 'GITHUB_PAT') return 'mock-pat' - if (key === 'CLASSIFICATION_RULES_JSON') - return JSON.stringify({ - version: '1.0.0', - updatedAt: '2026-08-08T10:00:00Z', - }) - return null - }, - setProperty: setPropertySpy, - }), - } - - expect(syncTwoWayRules()).toBe(true) - expect(setPropertySpy).toHaveBeenCalled() - }) - - test('commitRulesToGitHub fetches SHA when knownSha not provided', () => { - const base64 = Buffer.from(JSON.stringify({ version: '1.0.0' })).toString( - 'base64' - ) - const fetchSpy = jest - .fn() - .mockImplementationOnce(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: base64, sha: 'fetched-sha' }), - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 200, - getContentText: () => '{}', - })) - global.UrlFetchApp = { fetch: fetchSpy } - const result = commitRulesToGitHub({}, 'msg', 'token') - expect(result).toBe(true) - expect(fetchSpy).toHaveBeenCalledTimes(2) - const putPayload = JSON.parse(fetchSpy.mock.calls[1][1].payload) - expect(putPayload.sha).toBe('fetched-sha') - }) - - test('commitRulesToGitHub returns false on unexpected PUT status', () => { - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 403, - getContentText: () => 'Forbidden', - })), - } - expect(commitRulesToGitHub({}, 'msg', 'token', 'some-sha')).toBe(false) - }) - - test('commitRulesToGitHub returns false when PUT throws exception', () => { - global.UrlFetchApp = { - fetch: jest.fn(() => { - throw new Error('Network error during PUT') - }), - } - expect(commitRulesToGitHub({}, 'msg', 'token', 'some-sha')).toBe(false) - }) - - test('getGitHubApiUrl_ builds correct endpoint', () => { - const { getGitHubApiUrl_ } = require('../gitHubSync.gs') - global.GITHUB_REPO_OWNER = 'user-org' - global.GITHUB_REPO_NAME = 'self-private' - const url = getGitHubApiUrl_('test-file.md') - expect(url).toBe( - 'https://api.github.com/repos/user-org/self-private/contents/test-file.md' - ) - }) - - test('getGitHubApiUrl_ handles nested paths', () => { - const { getGitHubApiUrl_ } = require('../gitHubSync.gs') - global.GITHUB_REPO_OWNER = 'user-org' - global.GITHUB_REPO_NAME = 'self-private' - const url = getGitHubApiUrl_('folder/subfolder/file.json') - expect(url).toBe( - 'https://api.github.com/repos/user-org/self-private/contents/folder/subfolder/file.json' - ) - }) - - test('getGitHubHeaders_ includes required headers', () => { - const { getGitHubHeaders_ } = require('../gitHubSync.gs') - const headers = getGitHubHeaders_('test-token-123') - expect(headers.Authorization).toBe('token test-token-123') - expect(headers.Accept).toBe('application/vnd.github.v3+json') - expect(headers['User-Agent']).toBe('Google-Apps-Script') - }) - - test('getGitHubHeaders_ works with different tokens', () => { - const { getGitHubHeaders_ } = require('../gitHubSync.gs') - const headers = getGitHubHeaders_('another-token-xyz') - expect(headers.Authorization).toBe('token another-token-xyz') - }) - - test('getGitHubToken_ returns override token when provided', () => { - const { getGitHubToken_ } = require('../gitHubSync.gs') - const token = getGitHubToken_('override-token') - expect(token).toBe('override-token') - }) - - test('getGitHubToken_ falls back to script property', () => { - const { getGitHubToken_ } = require('../gitHubSync.gs') - global.PropertiesService = { - getScriptProperties: jest.fn(() => ({ - getProperty: jest.fn((key) => { - if (key === 'GITHUB_PAT') return 'script-property-token' - return null - }), - })), - } - const token = getGitHubToken_(null) - expect(token).toBe('script-property-token') - }) - - test('decodeBase64Content decodes base64 correctly', () => { - const { decodeBase64Content } = require('../gitHubSync.gs') - const encoded = Buffer.from('Hello World').toString('base64') - const decoded = decodeBase64Content(encoded) - expect(decoded).toBe('Hello World') - }) - - test('encodeBase64Content encodes to base64 correctly', () => { - const { encodeBase64Content } = require('../gitHubSync.gs') - const text = 'Test Content' - const encoded = encodeBase64Content(text) - expect(Buffer.from(encoded, 'base64').toString()).toBe(text) - }) - - test('fetchRulesFromGitHub handles network error with exception re-throw', () => { - global.UrlFetchApp = { - fetch: jest.fn(() => { - throw new Error('Connection timeout') - }), - } - expect(() => fetchRulesFromGitHub('rules.json', 'mock-token')).toThrow( - 'Connection timeout' - ) - }) - - test('commitRulesToGitHub handles 409 conflict when refresh also fails', () => { - const fetchSpy = jest - .fn() - .mockImplementationOnce(() => ({ - getResponseCode: () => 409, - getContentText: () => 'Conflict', - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 500, - getContentText: () => 'Fetch failed', - })) - global.UrlFetchApp = { fetch: fetchSpy } - const result = commitRulesToGitHub({}, 'msg', 'token', 'stale-sha') - expect(result).toBe(false) - }) - - test('syncTwoWayRules uses passed token for fetchRulesFromGitHub', () => { - const mockBase64 = Buffer.from( - JSON.stringify({ - version: '1.0.0', - updatedAt: '2026-08-08T10:00:00Z', - }) - ).toString('base64') - - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: mockBase64, sha: 'sha-1' }), - })), - } - - global.PropertiesService = { - getScriptProperties: () => ({ - getProperty: (key) => { - if (key === 'GITHUB_PAT') return 'mock-pat' - return null - }, - setProperty: jest.fn(), - }), - } - - const result = syncTwoWayRules() - expect(result).toBe(true) - }) - - test('syncTwoWayRules handles parsing error from local rules gracefully', () => { - const mockBase64 = Buffer.from( - JSON.stringify({ version: '1.0.0', updatedAt: '2026-08-08T10:00:00Z' }) - ).toString('base64') - - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: mockBase64, sha: 'sha-1' }), - })), - } - global.PropertiesService = { - getScriptProperties: () => ({ - getProperty: (key) => { - if (key === 'GITHUB_PAT') return 'mock-pat' - if (key === 'CLASSIFICATION_RULES_JSON') - return 'definitely not valid json at all' - return null - }, - setProperty: jest.fn(), - }), - } - - const result = syncTwoWayRules() - expect(result).toBe(false) - }) - - test('syncTwoWayRules handles invalid date strings with isNaN fallback', () => { - const mockBase64 = Buffer.from( - JSON.stringify({ version: '1.0.0', updatedAt: 'not-a-date' }) - ).toString('base64') - - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: mockBase64, sha: 'sha-1' }), - })), - } - global.PropertiesService = { - getScriptProperties: () => ({ - getProperty: (key) => { - if (key === 'GITHUB_PAT') return 'mock-pat' - if (key === 'CLASSIFICATION_RULES_JSON') - return JSON.stringify({ - version: '1.0.0', - updatedAt: 'also-not-a-date', - }) - return null - }, - setProperty: jest.fn(), - }), - } - - const result = syncTwoWayRules() - expect(result).toBe(true) - }) - - test('syncTwoWayRules logs when local commit fails during push', () => { - const mockBase64Remote = Buffer.from( - JSON.stringify({ version: '2.0.0', updatedAt: '2026-08-08T10:00:00Z' }) - ).toString('base64') - - global.UrlFetchApp = { - fetch: jest - .fn() - .mockImplementationOnce(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: mockBase64Remote, sha: 'sha-1' }), - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 500, - getContentText: () => 'Internal Server Error', - })), - } - global.PropertiesService = { - getScriptProperties: () => ({ - getProperty: (key) => { - if (key === 'GITHUB_PAT') return 'mock-pat' - if (key === 'CLASSIFICATION_RULES_JSON') - return JSON.stringify({ - version: '1.0.0', - updatedAt: '2026-08-08T11:00:00Z', - }) - return null - }, - setProperty: jest.fn(), - }), - } - - const consoleSpy = jest.spyOn(console, 'error').mockImplementation() - const result = syncTwoWayRules() - expect(result).toBe(false) - consoleSpy.mockRestore() - }) - - test('commitRulesToGitHub without explicit commitMessage uses default message', () => { - const putSpy = jest.fn(() => ({ - getResponseCode: () => 201, - getContentText: () => '{}', - })) - global.UrlFetchApp = { fetch: putSpy } - const result = commitRulesToGitHub({}, null, 'token', 'known-sha-123') - expect(result).toBe(true) - const payload = JSON.parse(putSpy.mock.calls[0][1].payload) - expect(payload.message).toContain('2-way Apps Script sync engine') - }) - - test('commitRulesToGitHub handles null knownSha when fetchRulesFromGitHub returns null', () => { - global.UrlFetchApp = { - fetch: jest.fn(() => ({ - getResponseCode: () => 404, - getContentText: () => 'Not Found', - })), - } - const result = commitRulesToGitHub({}, 'msg', 'token') - expect(result).toBe(false) - }) - - test('commitRulesToGitHub handles 409 conflict when refreshed rules are null', () => { - global.UrlFetchApp = { - fetch: jest - .fn() - .mockImplementationOnce(() => ({ - getResponseCode: () => 409, - getContentText: () => 'Conflict', - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 404, - getContentText: () => 'Not Found', - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 201, - getContentText: () => '{}', - })), - } - const result = commitRulesToGitHub({}, 'msg', 'token', 'stale-sha') - expect(result).toBe(true) - }) - - test('commitRulesToGitHub aborts on 409 when remote is newer', () => { - const remoteRules = { updatedAt: '2026-08-08T16:00:00Z' } - const base64 = Buffer.from(JSON.stringify(remoteRules)).toString('base64') - global.UrlFetchApp = { - fetch: jest - .fn() - .mockImplementationOnce(() => ({ - getResponseCode: () => 409, - getContentText: () => 'Conflict', - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: base64, sha: 'fresh-sha' }), - })), - } - const result = commitRulesToGitHub({}, 'msg', 'token', 'stale-sha') - expect(result).toBe(false) - expect(global.UrlFetchApp.fetch).toHaveBeenCalledTimes(2) - }) - - test('commitRulesToGitHub aborts on 409 when remote is newer despite an earlier-sorting timezone offset', () => { - // Same instant would be 15:00Z (newer than the local 14:00Z), but the string - // "09:00:00-06:00" sorts *before* "14:00:00Z", which a naive string compare - // would treat as older and wrongly overwrite. Numeric comparison must abort. - const remoteRules = { updatedAt: '2026-08-08T09:00:00-06:00' } - const base64 = Buffer.from(JSON.stringify(remoteRules)).toString('base64') - global.UrlFetchApp = { - fetch: jest - .fn() - .mockImplementationOnce(() => ({ - getResponseCode: () => 409, - getContentText: () => 'Conflict', - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: base64, sha: 'fresh-sha' }), - })), - } - const result = commitRulesToGitHub({}, 'msg', 'token', 'stale-sha') - expect(result).toBe(false) - expect(global.UrlFetchApp.fetch).toHaveBeenCalledTimes(2) - }) - - test('commitRulesToGitHub aborts on 409 when timestamps are the same instant but content differs', () => { - // Local updatedAt is mocked to 2026-08-08T14:00:00Z; remote uses an equivalent - // instant with an explicit +00:00 offset but different content. - const remoteRules = { - version: '9.9.9', - updatedAt: '2026-08-08T14:00:00+00:00', - } - const base64 = Buffer.from(JSON.stringify(remoteRules)).toString('base64') - global.UrlFetchApp = { - fetch: jest - .fn() - .mockImplementationOnce(() => ({ - getResponseCode: () => 409, - getContentText: () => 'Conflict', - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: base64, sha: 'fresh-sha' }), - })), - } - const result = commitRulesToGitHub( - { version: '1.0.0' }, - 'msg', - 'token', - 'stale-sha' - ) - expect(result).toBe(false) - expect(global.UrlFetchApp.fetch).toHaveBeenCalledTimes(2) - }) - - test('commitRulesToGitHub retries on 409 when timestamps are equal and content is identical', () => { - // Remote and local share the same updatedAt and content, so overwriting is safe. - const remoteRules = { - version: '1.0.0', - updatedAt: '2026-08-08T14:00:00Z', - } - const base64 = Buffer.from(JSON.stringify(remoteRules)).toString('base64') - global.UrlFetchApp = { - fetch: jest - .fn() - .mockImplementationOnce(() => ({ - getResponseCode: () => 409, - getContentText: () => 'Conflict', - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: base64, sha: 'fresh-sha' }), - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 201, - getContentText: () => '{}', - })), - } - const result = commitRulesToGitHub( - { version: '1.0.0' }, - 'msg', - 'token', - 'stale-sha' - ) - expect(result).toBe(true) - expect(global.UrlFetchApp.fetch).toHaveBeenCalledTimes(3) - }) - - test('commitRulesToGitHub aborts on 409 when a timestamp is invalid and content differs', () => { - const remoteRules = { version: '2.0.0', updatedAt: 'not-a-real-date' } - const base64 = Buffer.from(JSON.stringify(remoteRules)).toString('base64') - global.UrlFetchApp = { - fetch: jest - .fn() - .mockImplementationOnce(() => ({ - getResponseCode: () => 409, - getContentText: () => 'Conflict', - })) - .mockImplementationOnce(() => ({ - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ content: base64, sha: 'fresh-sha' }), - })), - } - const result = commitRulesToGitHub( - { version: '1.0.0' }, - 'msg', - 'token', - 'stale-sha' - ) - expect(result).toBe(false) - expect(global.UrlFetchApp.fetch).toHaveBeenCalledTimes(2) - }) - - test('rulesContentEquals_ ignores the internal _sha field', () => { - expect( - rulesContentEquals_( - { version: '1.0.0', _sha: 'abc' }, - { version: '1.0.0', _sha: 'xyz' } - ) - ).toBe(true) - expect( - rulesContentEquals_({ version: '1.0.0' }, { version: '2.0.0' }) - ).toBe(false) - }) - - test('isSafeToOverwriteRemote_ handles valid, equal, and invalid timestamps', () => { - // Remote strictly newer by instant -> not safe. - expect( - isSafeToOverwriteRemote_( - { updatedAt: '2026-08-08T15:00:00Z', version: '1.0.0' }, - { updatedAt: '2026-08-08T14:00:00Z', version: '1.0.0' }, - '2026-08-08T14:00:00Z' - ) - ).toBe(false) - // Remote older by instant -> safe regardless of content. - expect( - isSafeToOverwriteRemote_( - { updatedAt: '2026-08-08T13:00:00Z', version: '9.9.9' }, - { updatedAt: '2026-08-08T14:00:00Z', version: '1.0.0' }, - '2026-08-08T14:00:00Z' - ) - ).toBe(true) - // Equal instant, identical content -> safe. - expect( - isSafeToOverwriteRemote_( - { updatedAt: '2026-08-08T14:00:00Z', version: '1.0.0' }, - { updatedAt: '2026-08-08T14:00:00Z', version: '1.0.0' }, - '2026-08-08T14:00:00Z' - ) - ).toBe(true) - }) - }) -}) diff --git a/src/gmail-ai-classifier/tests/github-sync.test.js b/src/gmail-ai-classifier/tests/github-sync.test.js new file mode 100644 index 00000000..5f7c1ab3 --- /dev/null +++ b/src/gmail-ai-classifier/tests/github-sync.test.js @@ -0,0 +1,1048 @@ +const { + appendMarkdownEntryToGitHubRepo, + executeGitHubCommit, + extractTopicTitleFromPath, + insertEntryIntoLogSection, + assertClean_, + assertNoAsciiReplacement_, + RULE6_PATTERNS, + formatProgressiveDisclosureEntry, + getNotePathForDomain, + getSearchDateRange_, +} = require('../src/index.js') + +describe('GitHub Sync & Rule 6 Mojibake Guards', () => { + // ------------------------------------------------------------------------- + // RULE6_PATTERNS & assertClean_ + // ------------------------------------------------------------------------- + describe('RULE6_PATTERNS', () => { + test('defines all required mojibake detection patterns and messages', () => { + expect(Array.isArray(RULE6_PATTERNS)).toBe(true) + expect(RULE6_PATTERNS).toHaveLength(4) + RULE6_PATTERNS.forEach(([regex, message]) => { + expect(regex).toBeInstanceOf(RegExp) + expect(typeof message).toBe('string') + }) + }) + }) + + describe('assertClean_', () => { + test('passes on clean strings containing non-ASCII UTF-8 characters', () => { + const cleanSamples = [ + '### 2026-09-12 — PayPal Payment Receipt: Cloudflare Inc', + '- **Subject**: Don’t forget your $10.46\u00A0USD receipt · Monthly update', + 'Status: ✅ Verified 🐝 Apiary Alert 🚀 Deployed', + 'Normal ASCII text with no special characters', + 'Empty text should pass', + ] + cleanSamples.forEach((text) => { + expect(() => assertClean_(text, 'clean sample')).not.toThrow() + }) + }) + + test('passes on null or empty input', () => { + expect(() => assertClean_(null, 'null input')).not.toThrow() + expect(() => assertClean_('', 'empty input')).not.toThrow() + }) + + test('passes on genuine question marks and URLs with query parameters', () => { + const genuineQuestions = [ + 'Did you sign in from a new device?', + 'Is this payment expected? (Yes/No)', + 'What? Are you sure?', + 'Can we meet today? Thanks!', + 'Questions? Contact support@example.com.', + 'View invoice at https://example.com/view?token=abc&mode=pdf or [Attachment](https://drive.google.com/file/d/123/view?usp=sharing)', + ] + genuineQuestions.forEach((text) => { + expect(() => assertClean_(text, 'genuine question')).not.toThrow() + }) + }) + + test('throws on "\\S \\? \\S" pattern (flattened em dash or middle dot)', () => { + expect(() => + assertClean_('2026-09-12 ? PayPal Receipt', 'flattened heading') + ).toThrow(/Rule 6.*' \? ' between words/) + + expect(() => + assertClean_('Item 1 ? Item 2', 'flattened dot separator') + ).toThrow(/Rule 6.*' \? ' between words/) + }) + + test('throws on "??" pattern (flattened multi-byte emoji)', () => { + expect(() => + assertClean_('Notification ?? Alert received', 'flattened emoji') + ).toThrow(/Rule 6.*'\?\?'/) + }) + + test('throws on "[A-Za-z]\\?[A-Za-z]" pattern (flattened curly apostrophe/quote)', () => { + expect(() => + assertClean_('Don?t miss this update', 'flattened apostrophe') + ).toThrow(/Rule 6.*'\?' inside a word/) + + expect(() => + assertClean_('It?s ready for review', 'flattened apostrophe') + ).toThrow(/Rule 6.*'\?' inside a word/) + }) + + test('throws on U+FFFD replacement character', () => { + expect(() => + assertClean_('Bad character \uFFFD in stream', 'replacement char') + ).toThrow(/Rule 6.*U\+FFFD/) + }) + }) + + // ------------------------------------------------------------------------- + // assertNoAsciiReplacement_ + // ------------------------------------------------------------------------- + describe('assertNoAsciiReplacement_', () => { + test('passes when source and rendered both contain non-ASCII chars and rendered has a genuine question mark', () => { + const source = + 'PayPal — Cloudflare Inc: $10.46\u00A0USD · Don’t miss ✅ 🐝' + const rendered = + '### 2026-09-12 — PayPal: $10.46\u00A0USD · Don’t miss ✅ 🐝\n' + + '- **Subject**: Did you sign in from a new device?' + expect(() => assertNoAsciiReplacement_(source, rendered)).not.toThrow() + }) + + test('passes when rendered contains no question marks', () => { + const source = 'PayPal — Cloudflare Inc' + const rendered = '### 2026-09-12 — PayPal — Cloudflare Inc' + expect(() => assertNoAsciiReplacement_(source, rendered)).not.toThrow() + }) + + test('passes on null or empty input', () => { + expect(() => assertNoAsciiReplacement_(null, 'text')).not.toThrow() + expect(() => assertNoAsciiReplacement_('text', null)).not.toThrow() + expect(() => assertNoAsciiReplacement_('', '')).not.toThrow() + }) + + test('passes when source contains only ASCII and rendered contains question marks', () => { + const source = 'Plain ASCII subject' + const rendered = 'Is this a question? Yes it is.' + expect(() => assertNoAsciiReplacement_(source, rendered)).not.toThrow() + }) + + test('throws when non-ASCII characters from source are flattened to "?" in rendered', () => { + const source = 'PayPal — Cloudflare · Don’t forget ✅ 🐝' + const flattenedRendered = 'PayPal ? Cloudflare ? Don?t forget ?? ??' + + expect(() => + assertNoAsciiReplacement_(source, flattenedRendered) + ).toThrow( + /Rule 6: refusing to write text that flattened non-ASCII to "\?"/ + ) + }) + + test('error message lists the specific lost characters', () => { + const source = 'Costco Anywhere Visa® Card — $10.46\u00A0USD' + const flattenedRendered = 'Costco Anywhere Visa? Card ? $10.46?USD' + + expect(() => + assertNoAsciiReplacement_(source, flattenedRendered) + ).toThrow(/®/) + }) + }) + + // ------------------------------------------------------------------------- + // extractTopicTitleFromPath & insertEntryIntoLogSection + // ------------------------------------------------------------------------- + describe('extractTopicTitleFromPath', () => { + test('extracts and titles topic from standard nested path', () => { + expect( + extractTopicTitleFromPath( + 'household/technology/digital-backups/index.md' + ) + ).toBe('Digital Backups') + expect(extractTopicTitleFromPath('household/kids/index.md')).toBe('Kids') + expect(extractTopicTitleFromPath('household/finances/index.md')).toBe( + 'Finances' + ) + }) + + test('handles single segment paths and strips the file extension', () => { + expect(extractTopicTitleFromPath('digital-backups.md')).toBe( + 'Digital Backups' + ) + }) + + test('handles null, undefined, empty string, and root slash safely without throwing', () => { + expect(extractTopicTitleFromPath(null)).toBe('Ingested Records') + expect(extractTopicTitleFromPath(undefined)).toBe('Ingested Records') + expect(extractTopicTitleFromPath('')).toBe('Ingested Records') + expect(extractTopicTitleFromPath('/')).toBe('Ingested Records') + }) + }) + + describe('insertEntryIntoLogSection', () => { + test('handles null or empty content safely without throwing', () => { + expect(insertEntryIntoLogSection(null, 'entry')).toBe('entry') + expect(insertEntryIntoLogSection('content', null)).toBe('content') + expect(insertEntryIntoLogSection(null, null)).toBe('') + }) + test('inserts entry before </details> marker when present', () => { + const doc = + '# Notes\n\n## 3. Ingested Activity Logs\n<details open><summary><b>Activity Logs</b></summary>\n### Older Entry\n</details>\n' + const newEntry = '### 2026-09-12 — New Entry' + const result = insertEntryIntoLogSection(doc, newEntry) + + expect(result).toContain(newEntry + '\n</details>') + expect(result.indexOf(newEntry)).toBeLessThan( + result.indexOf('</details>') + ) + }) + + test('inserts entry after "## 3. Ingested Activity" marker when </details> is absent', () => { + const doc = + '# Notes\n\n## 3. Ingested Activity Logs\n### Older Entry\n\n## 4. Other' + const newEntry = '### 2026-09-12 — New Entry' + const result = insertEntryIntoLogSection(doc, newEntry) + + const sectionIdx = result.indexOf('## 3. Ingested Activity Logs') + const entryIdx = result.indexOf(newEntry) + expect(entryIdx).toBeGreaterThan(sectionIdx) + }) + + test('appends entry at end of file when no markers match', () => { + const doc = '# Just a title\nSome content' + const newEntry = '### 2026-09-12 — New Entry' + const result = insertEntryIntoLogSection(doc, newEntry) + + expect(result).toBe(doc + '\n' + newEntry) + }) + }) + + // ------------------------------------------------------------------------- + // formatProgressiveDisclosureEntry & getNotePathForDomain + // ------------------------------------------------------------------------- + describe('formatProgressiveDisclosureEntry', () => { + test('formats markdown entry with em dash in heading and all metadata fields', () => { + const entry = formatProgressiveDisclosureEntry( + '2026-09-12', + 'PayPal Payment Receipt: Cloudflare Inc', + 'service@paypal.com', + 'Cloudflare Inc: $10.46\u00A0USD', + 'A payment receipt confirms a successful transaction of $10.46 USD.', + 'user@example.com' + ) + + expect(entry).toContain( + '### 2026-09-12 — PayPal Payment Receipt: Cloudflare Inc' + ) + expect(entry).toContain('- **Account**: user@example.com') + expect(entry).toContain('- **From**: service@paypal.com') + expect(entry).toContain('- **Subject**: Cloudflare Inc: $10.46\u00A0USD') + expect(entry).toContain( + '- **Summary**:\n > A payment receipt confirms a successful transaction of $10.46 USD.' + ) + }) + + test('handles entry without summaryText', () => { + const entry = formatProgressiveDisclosureEntry( + '2026-09-12', + 'Title Only', + 'sender@example.com', + 'Subject Line', + null, + 'user@example.com' + ) + + expect(entry).toContain('### 2026-09-12 — Title Only') + expect(entry).not.toContain('**Summary**') + }) + }) + + describe('getNotePathForDomain', () => { + test('routes all 7 canonical domains to their correct markdown notes', () => { + expect(getNotePathForDomain('01_Household')).toBe('01_Household/index.md') + expect(getNotePathForDomain('02_Finance_Legal')).toBe( + '02_Finance_Legal/index.md' + ) + expect(getNotePathForDomain('03_Vehicles')).toBe('03_Vehicles/index.md') + expect(getNotePathForDomain('04_Family_Health')).toBe( + '04_Family_Health/index.md' + ) + expect(getNotePathForDomain('05_Tech_Infrastructure')).toBe( + '05_Tech_Infrastructure/index.md' + ) + expect(getNotePathForDomain('06_Work_Career')).toBe( + '06_Work_Career/index.md' + ) + expect(getNotePathForDomain('07_Community_NonProfit')).toBe( + '07_Community_NonProfit/index.md' + ) + expect(getNotePathForDomain('Unknown_Domain')).toBeNull() + }) + }) + + // ------------------------------------------------------------------------- + // End-to-End UTF-8 Round-Trip Verification (Step 4 Proof) + // ------------------------------------------------------------------------- + describe('UTF-8 Round-Trip Proof & Encoding Integrity', () => { + test('sample note body with em dash, curly quotes, middle dot, NBSP, checkmark, and astral emoji round-trips byte-identically through real GET-decode -> append -> PUT-encode', () => { + // 1. Initial sample note containing all critical non-ASCII UTF-8 characters and genuine question mark + const initialDoc = [ + '---', + 'title: Digital Backups', + 'created: 2026-09-12', + 'notebook: household', + 'section: general', + '---', + '', + '# Digital Backups', + '', + '## 1. Executive Summary & Active Status', + '- System integrity: ✅ Verified 100% operational.', + '- Contact: Don’t hesitate · Support available 24/7.', + '', + '## 2. Key References & Quick Links', + '| Topic | Asset |', + '| :--- | :--- |', + '| AWS S3 | Cloud Backup Archive |', + '', + '## 3. Ingested Activity Logs', + '<details open><summary><b>Activity Logs</b></summary>', + '### 2026-09-11 — Prior Backup Verification: Cloudflare Inc', + '- **Account**: user@example.com', + '- **From**: "service@paypal.com" <service@paypal.com>', + '- **Subject**: Monthly Retainer: $10.46\u00A0USD · Don’t forget', + '- **Summary**:', + ' > Previous backup completed successfully with 🐝 Honeybee Telemetry and 🚀 FastSync.', + '</details>', + ].join('\n') + + // Encode initial document as base64 (as returned by GitHub Contents API) + const mockGitHubGetBase64 = Utilities.base64Encode( + Utilities.newBlob(initialDoc).getBytes() + ) + + // Step A: Real GET-decode + const decodedExistingContent = Utilities.newBlob( + Utilities.base64Decode(mockGitHubGetBase64) + ).getDataAsString() + + expect(decodedExistingContent).toBe(initialDoc) + + // Step B: Create and append new progressive disclosure entry + const newEntry = formatProgressiveDisclosureEntry( + '2026-09-12', + 'PayPal Payment Receipt: Cloudflare Inc', + '"service@paypal.com" <service@paypal.com>', + 'Cloudflare Inc: $10.46\u00A0USD · Did you sign in from a new device?', + 'A payment receipt confirms a successful transaction for Costco Anywhere Visa® Card.', + 'user@example.com' + ) + + // Verify heading contains em dash U+2014, not '?' + expect(newEntry).toContain( + '### 2026-09-12 — PayPal Payment Receipt: Cloudflare Inc' + ) + expect(newEntry).not.toContain('2026-09-12 ?') + + // Step C: Section insertion + const updatedContent = insertEntryIntoLogSection( + decodedExistingContent, + newEntry + ) + + // Step D: Rule 6 assertions + expect(() => + assertClean_( + newEntry, + 'new entry for household/our-technology/digital-backups/index.md' + ) + ).not.toThrow() + expect(() => + assertClean_( + updatedContent, + 'updated content for household/our-technology/digital-backups/index.md' + ) + ).not.toThrow() + expect(() => + assertNoAsciiReplacement_(decodedExistingContent, updatedContent) + ).not.toThrow() + + // Step E: Real PUT-encode + const putBase64 = Utilities.base64Encode( + Utilities.newBlob(updatedContent).getBytes() + ) + + // Step F: Simulate next GET-decode from GitHub to prove byte-identical round trip + const roundTrippedContent = Utilities.newBlob( + Utilities.base64Decode(putBase64) + ).getDataAsString() + + // Verify exact byte and character identity + expect(roundTrippedContent).toBe(updatedContent) + expect(Buffer.from(roundTrippedContent, 'utf8')).toEqual( + Buffer.from(updatedContent, 'utf8') + ) + + // Verify all non-ASCII characters preserved in round-tripped content + expect(roundTrippedContent).toContain('—') // em dash + expect(roundTrippedContent).toContain('’') // curly apostrophe + expect(roundTrippedContent).toContain('·') // middle dot + expect(roundTrippedContent).toContain('\u00A0') // non-breaking space + expect(roundTrippedContent).toContain('®') // registered trademark + expect(roundTrippedContent).toContain('✅') // checkmark + expect(roundTrippedContent).toContain('🐝') // astral emoji (U+1F41D) + expect(roundTrippedContent).toContain('🚀') // astral emoji (U+1F680) + expect(roundTrippedContent).toContain( + 'Did you sign in from a new device?' + ) // genuine question mark + + // Verify NO mojibake was introduced + expect(() => + assertClean_(roundTrippedContent, 'round-tripped content') + ).not.toThrow() + }) + + test('demonstrates that default US_ASCII base64Encode causes mojibake and gets caught by assertNoAsciiReplacement_', () => { + const sourceWithUtf8 = + '### 2026-09-12 — PayPal: $10.46\u00A0USD · Don’t forget 🚀' + + // Simulate what the old broken code did: Utilities.base64Encode(string) without converting to UTF-8 bytes + // In GAS, base64Encode(string) flattens non-ASCII chars to '?' + const asciiFlattenedBase64 = Utilities.base64Encode(sourceWithUtf8) + const flattenedContent = Utilities.newBlob( + Utilities.base64Decode(asciiFlattenedBase64) + ).getDataAsString() + + expect(flattenedContent).toContain('?') + expect(() => + assertClean_(flattenedContent, 'ascii flattened text') + ).toThrow(/Rule 6/) + expect(() => + assertNoAsciiReplacement_(sourceWithUtf8, flattenedContent) + ).toThrow( + /Rule 6: refusing to write text that flattened non-ASCII to "\?"/ + ) + }) + }) + + // ------------------------------------------------------------------------- + // executeGitHubCommit & appendMarkdownEntryToGitHubRepo + // ------------------------------------------------------------------------- + describe('executeGitHubCommit & appendMarkdownEntryToGitHubRepo', () => { + const originalProperties = global.PropertiesService + const originalUrlFetchApp = global.UrlFetchApp + + beforeEach(() => { + global.PropertiesService = { + getScriptProperties: () => ({ + getProperty: (k) => { + if (k === 'GITHUB_REPO_OWNER') return 'test-owner' + if (k === 'GITHUB_REPO_NAME') return 'test-repo' + if (k === 'GITHUB_PAT') return 'ghp_valid' + return '' + }, + }), + } + }) + + afterEach(() => { + global.PropertiesService = originalProperties + global.UrlFetchApp = originalUrlFetchApp + }) + + test('returns false and logs when GITHUB_PAT property is not set', () => { + global.PropertiesService = { + getScriptProperties: () => ({ + getProperty: (k) => (k === 'GITHUB_PAT' ? null : 'test-val'), + }), + } + + const result = appendMarkdownEntryToGitHubRepo( + 'household/finances/index.md', + '### 2026-09-12 — Test Entry', + 'feat(ingestion): Test' + ) + expect(result).toBe(false) + }) + + test('fails closed and returns false when GITHUB_REPO_OWNER or GITHUB_REPO_NAME is unconfigured', () => { + global.PropertiesService = { + getScriptProperties: () => ({ + getProperty: (k) => (k === 'GITHUB_PAT' ? 'ghp_valid' : ''), + }), + } + + expect( + appendMarkdownEntryToGitHubRepo( + 'household/finances/index.md', + '### 2026-09-12 — Test Entry', + 'feat(ingestion): Test' + ) + ).toBe(false) + + expect( + executeGitHubCommit( + 'household/finances/index.md', + '### 2026-09-12 — Test Entry', + 'feat(ingestion): Test', + 'ghp_valid' + ) + ).toBe(false) + }) + + test('commits successfully (200 GET -> 200 PUT) with UTF-8 content', () => { + const existingDoc = + '# Notes\n\n## 3. Ingested Activity Logs\n<details open><summary>Logs</summary>\n### 2026-09-11 — Existing\n</details>' + const existingBase64 = Utilities.base64Encode( + Utilities.newBlob(existingDoc).getBytes() + ) + + let capturedPutPayload = null + global.UrlFetchApp = { + fetch: jest.fn((url, options) => { + if (options.method === 'get') { + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ sha: 'abc123sha', content: existingBase64 }), + } + } + if (options.method === 'put') { + capturedPutPayload = JSON.parse(options.payload) + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ commit: { sha: 'new123' } }), + } + } + return { getResponseCode: () => 400, getContentText: () => '{}' } + }), + } + + const entryMd = + '### 2026-09-12 — PayPal: $10.46\u00A0USD · New Entry ✅\n- **From**: service@paypal.com' + const commitMsg = 'feat(ingestion): PayPal: $10.46\u00A0USD' + + const success = executeGitHubCommit( + 'household/finances/index.md', + entryMd, + commitMsg, + 'ghp_faketoken' + ) + + expect(success).toBe(true) + expect(capturedPutPayload).toBeDefined() + expect(capturedPutPayload.sha).toBe('abc123sha') + expect(capturedPutPayload.message).toBe(commitMsg) + + // Decode PUT base64 payload to verify UTF-8 contents + const putContent = Utilities.newBlob( + Utilities.base64Decode(capturedPutPayload.content) + ).getDataAsString() + expect(putContent).toContain( + '### 2026-09-12 — PayPal: $10.46\u00A0USD · New Entry ✅' + ) + expect(putContent).toContain('### 2026-09-11 — Existing') + }) + + test('auto-creates new note on 404 GET and commits with 201 PUT in UTF-8', () => { + let capturedPutPayload = null + global.UrlFetchApp = { + fetch: jest.fn((url, options) => { + if (options.method === 'get') { + return { + getResponseCode: () => 404, + getContentText: () => JSON.stringify({ message: 'Not Found' }), + } + } + if (options.method === 'put') { + capturedPutPayload = JSON.parse(options.payload) + return { + getResponseCode: () => 201, + getContentText: () => + JSON.stringify({ commit: { sha: 'created123' } }), + } + } + return { getResponseCode: () => 400, getContentText: () => '{}' } + }), + } + + const entryMd = '### 2026-09-12 — Initial Log Entry' + const success = executeGitHubCommit( + 'household/vehicles/index.md', + entryMd, + 'feat(ingestion): Initial Vehicle Log', + 'ghp_faketoken' + ) + + expect(success).toBe(true) + expect(capturedPutPayload).toBeDefined() + expect(capturedPutPayload.sha).toBeUndefined() // No SHA on new file creation + + const putContent = Utilities.newBlob( + Utilities.base64Decode(capturedPutPayload.content) + ).getDataAsString() + expect(putContent).toContain('title: Vehicles') + expect(putContent).toContain('### 2026-09-12 — Initial Log Entry') + }) + + test('skips idempotently when entry is already in the document', () => { + const existingDoc = + '# Notes\n\n## 3. Ingested Activity Logs\n<details open><summary>Logs</summary>\n### 2026-09-12 — Already Present\n</details>' + const existingBase64 = Utilities.base64Encode( + Utilities.newBlob(existingDoc).getBytes() + ) + + const mockFetch = jest.fn((url, options) => { + if (options.method === 'get') { + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ sha: 'abc123sha', content: existingBase64 }), + } + } + return { getResponseCode: () => 200, getContentText: () => '{}' } + }) + global.UrlFetchApp = { fetch: mockFetch } + + const result = executeGitHubCommit( + 'household/finances/index.md', + '### 2026-09-12 — Already Present', + 'feat(ingestion): Already Present', + 'ghp_faketoken' + ) + + expect(result).toBe('IDEMPOTENT_SKIP') + // Only GET should have been called, no PUT + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + test('returns false on SHA collision 409', () => { + global.UrlFetchApp = { + fetch: jest.fn((url, options) => { + if (options.method === 'get') { + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + sha: 'abc123sha', + content: Utilities.base64Encode( + Utilities.newBlob('# Doc').getBytes() + ), + }), + } + } + if (options.method === 'put') { + return { + getResponseCode: () => 409, + getContentText: () => JSON.stringify({ message: 'Conflict' }), + } + } + }), + } + + const result = executeGitHubCommit( + 'household/finances/index.md', + '### 2026-09-12 — New Entry', + 'feat(ingestion): New Entry', + 'ghp_faketoken' + ) + expect(result).toBe(false) + }) + + test('returns false on unexpected GET status', () => { + global.UrlFetchApp = { + fetch: jest.fn(() => ({ + getResponseCode: () => 500, + getContentText: () => 'Server error', + })), + } + + const result = executeGitHubCommit( + 'household/finances/index.md', + '### 2026-09-12 — New Entry', + 'feat(ingestion): New Entry', + 'ghp_faketoken' + ) + expect(result).toBe(false) + }) + + test('refuses to commit and returns false when entry carries mojibake', () => { + const existingDoc = + '# Notes\n\n## 3. Ingested Activity Logs\n<details open><summary>Logs</summary>\n</details>' + global.UrlFetchApp = { + fetch: jest.fn((url, options) => { + if (options.method === 'get') { + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + sha: 'sha1', + content: Utilities.base64Encode( + Utilities.newBlob(existingDoc).getBytes() + ), + }), + } + } + return { getResponseCode: () => 200, getContentText: () => '{}' } + }), + } + + const corruptedEntry = + '### 2026-09-12 ? PayPal Payment Receipt: Cloudflare Inc' + const result = executeGitHubCommit( + 'household/finances/index.md', + corruptedEntry, + 'feat(ingestion): Corrupted', + 'ghp_faketoken' + ) + + expect(result).toBe(false) + // GET was called, but PUT was NEVER called because assertion threw + expect(global.UrlFetchApp.fetch).toHaveBeenCalledTimes(1) + }) + + test('appendMarkdownEntryToGitHubRepo retries up to 3 times on transient failure', () => { + jest.useFakeTimers() + try { + global.PropertiesService = { + getScriptProperties: () => ({ + getProperty: (k) => { + if (k === 'GITHUB_PAT') return 'ghp_valid' + if (k === 'GITHUB_REPO_OWNER') return 'test-owner' + if (k === 'GITHUB_REPO_NAME') return 'test-repo' + return '' + }, + }), + } + + let attempts = 0 + global.UrlFetchApp = { + fetch: jest.fn((url, options) => { + if (options.method === 'get') { + attempts++ + if (attempts < 3) { + return { + getResponseCode: () => 500, + getContentText: () => 'Internal Error', + } + } + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + sha: 'sha1', + content: Utilities.base64Encode( + Utilities.newBlob( + '# Doc\n<details open><summary>Logs</summary>\n</details>' + ).getBytes() + ), + }), + } + } + return { + getResponseCode: () => 200, + getContentText: () => JSON.stringify({ commit: { sha: 'sha2' } }), + } + }), + } + + const success = appendMarkdownEntryToGitHubRepo( + 'household/finances/index.md', + '### 2026-09-12 — Test Retry', + 'feat(ingestion): Retry' + ) + + expect(success).toBe(true) + expect(attempts).toBe(3) + } finally { + jest.useRealTimers() + } + }) + + test('returns false on an unexpected PUT status (e.g. 422)', () => { + global.UrlFetchApp = { + fetch: jest.fn((url, options) => { + if (options.method === 'get') { + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + sha: 'abc123sha', + content: Utilities.base64Encode( + Utilities.newBlob( + '# Doc\n<details open><summary>Logs</summary>\n</details>' + ).getBytes() + ), + }), + } + } + return { + getResponseCode: () => 422, + getContentText: () => JSON.stringify({ message: 'Unprocessable' }), + } + }), + } + + const result = executeGitHubCommit( + 'household/finances/index.md', + '### 2026-09-12 — New Entry', + 'feat(ingestion): New Entry', + 'ghp_faketoken' + ) + expect(result).toBe(false) + }) + + test('appendMarkdownEntryToGitHubRepo returns false after exhausting retries on persistent conflict', () => { + jest.useFakeTimers() + try { + global.PropertiesService = { + getScriptProperties: () => ({ + getProperty: (k) => { + if (k === 'GITHUB_PAT') return 'ghp_valid' + if (k === 'GITHUB_REPO_OWNER') return 'test-owner' + if (k === 'GITHUB_REPO_NAME') return 'test-repo' + return '' + }, + }), + } + + let getCalls = 0 + global.UrlFetchApp = { + fetch: jest.fn((url, options) => { + if (options.method === 'get') { + getCalls++ + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + sha: 'sha' + getCalls, + content: Utilities.base64Encode( + Utilities.newBlob( + '# Doc\n<details open><summary>Logs</summary>\n</details>' + ).getBytes() + ), + }), + } + } + // Every PUT collides (HTTP 409) -> executeGitHubCommit returns false + return { + getResponseCode: () => 409, + getContentText: () => JSON.stringify({ message: 'Conflict' }), + } + }), + } + + const result = appendMarkdownEntryToGitHubRepo( + 'household/finances/index.md', + '### 2026-09-12 — Persistent Conflict', + 'feat(ingestion): Conflict' + ) + + expect(result).toBe(false) + expect(getCalls).toBe(3) // one full GET->PUT cycle per retry attempt + } finally { + jest.useRealTimers() + } + }) + }) + + // ------------------------------------------------------------------------- + // getSearchDateRange_ + // ------------------------------------------------------------------------- + describe('getSearchDateRange_', () => { + test('computes correct after and before dates for given date and offset', () => { + const range = getSearchDateRange_('2026-07-23', 2) + expect(range.after).toBe('2026/07/21') + expect(range.before).toBe('2026/07/26') + }) + }) + + // ------------------------------------------------------------------------- + // gitHubSync.gs parity with src/index.js + // ------------------------------------------------------------------------- + describe('gitHubSync.gs parity with src/index.js', () => { + const gitHubSyncGs = require('../gitHubSync.gs') + const originalProperties = global.PropertiesService + const originalUrlFetchApp = global.UrlFetchApp + + beforeEach(() => { + global.PropertiesService = { + getScriptProperties: () => ({ + getProperty: (k) => { + if (k === 'GITHUB_REPO_OWNER') return 'test-owner' + if (k === 'GITHUB_REPO_NAME') return 'test-repo' + if (k === 'GITHUB_PAT') return 'token-xyz' + return '' + }, + }), + } + }) + + afterEach(() => { + global.PropertiesService = originalProperties + global.UrlFetchApp = originalUrlFetchApp + }) + + test('extractTopicTitleFromPath handles single segment paths and strips file extension', () => { + expect(gitHubSyncGs.extractTopicTitleFromPath('digital-backups.md')).toBe( + 'Digital Backups' + ) + }) + + test('executeGitHubCommit uses Bearer auth header, API version header, and URL-encoded path segments', () => { + let capturedUrl = null + let capturedHeaders = null + global.UrlFetchApp = { + fetch: jest.fn((url, options) => { + capturedUrl = url + capturedHeaders = options.headers + if (options.method === 'get') { + return { + getResponseCode: () => 404, + getContentText: () => '{}', + } + } + return { + getResponseCode: () => 201, + getContentText: () => JSON.stringify({ commit: { sha: 'sha456' } }), + } + }), + } + + const success = gitHubSyncGs.executeGitHubCommit( + '01_Household/My Notes & Docs/index.md', + '### 2026-10-15 — Test Title', + 'feat(ingestion): Test', + 'token-xyz' + ) + expect(success).toBe(true) + expect(capturedUrl).toBe( + 'https://api.github.com/repos/test-owner/test-repo/contents/01_Household/My%20Notes%20%26%20Docs/index.md' + ) + expect(capturedHeaders).toMatchObject({ + Authorization: 'Bearer token-xyz', + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }) + }) + + test('executeGitHubCommit handles wrapped newlines in base64 content from GitHub Contents API', () => { + const rawMarkdown = + '# Ingested Logs\n## 3. Ingested Activity Logs\n<details open><summary>Logs</summary>\n</details>' + const encoded = Buffer.from(rawMarkdown, 'utf-8').toString('base64') + const wrappedBase64 = encoded.match(/.{1,60}/g).join('\n') + + let putPayload = null + global.UrlFetchApp = { + fetch: jest.fn((url, options) => { + if (options.method === 'get') { + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + sha: 'sha123', + content: wrappedBase64, + }), + } + } + if (options.method === 'put') { + putPayload = JSON.parse(options.payload) + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ commit: { sha: 'sha456' } }), + } + } + }), + } + + const success = gitHubSyncGs.executeGitHubCommit( + '01_Household/index.md', + '### 2026-10-15 — Test Title\n- **Subject**: Test', + 'feat(ingestion): Test', + 'token-xyz' + ) + expect(success).toBe(true) + expect(putPayload).toBeDefined() + const decodedPut = Utilities.newBlob( + Utilities.base64Decode(putPayload.content) + ).getDataAsString() + expect(decodedPut).toContain('### 2026-10-15 — Test Title') + }) + + test('executeGitHubCommit validates Base64 round trip at the payload boundary', () => { + const existingBase64 = Utilities.base64Encode( + Utilities.newBlob( + '# Doc\n<details open><summary>Logs</summary>\n</details>' + ).getBytes() + ) + let getCalled = false + let putCalled = false + global.UrlFetchApp = { + fetch: jest.fn((url, options) => { + if (options.method === 'get') { + getCalled = true + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + sha: 'sha123', + content: existingBase64, + }), + } + } + if (options.method === 'put') { + putCalled = true + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ commit: { sha: 'sha456' } }), + } + } + }), + } + + let decodeCalls = 0 + jest.spyOn(Utilities, 'base64Decode').mockImplementation((b64) => { + decodeCalls++ + const decoded = Buffer.from(b64, 'base64').toString('utf-8') + if (decodeCalls === 1) { + // GET decode succeeds cleanly with valid byte array + return Array.from(Buffer.from(decoded, 'utf-8')) + } + // PUT round-trip verification detects ASCII-flattened mojibake + const flattened = decoded.replace(/—/g, '?') + return Array.from(Buffer.from(flattened, 'utf-8')) + }) + + try { + const result = gitHubSyncGs.executeGitHubCommit( + '01_Household/index.md', + '### 2026-10-15 — Heading with em dash', + 'feat(ingestion): Test', + 'token-xyz' + ) + expect(getCalled).toBe(true) + expect(decodeCalls).toBe(2) + expect(result).toBe(false) + expect(putCalled).toBe(false) + } finally { + Utilities.base64Decode.mockRestore() + } + }) + + test('extractTopicTitleFromPath and insertEntryIntoLogSection handle null inputs safely in gitHubSync.gs', () => { + expect(gitHubSyncGs.extractTopicTitleFromPath(null)).toBe( + 'Ingested Records' + ) + expect(gitHubSyncGs.extractTopicTitleFromPath(undefined)).toBe( + 'Ingested Records' + ) + expect(gitHubSyncGs.extractTopicTitleFromPath('')).toBe( + 'Ingested Records' + ) + expect(gitHubSyncGs.insertEntryIntoLogSection(null, 'entry')).toBe( + 'entry' + ) + expect(gitHubSyncGs.insertEntryIntoLogSection('content', null)).toBe( + 'content' + ) + }) + }) +}) diff --git a/src/gmail-ai-classifier/tests/performance-scalability.test.js b/src/gmail-ai-classifier/tests/performance-scalability.test.js index 16f58db5..43bd3644 100644 --- a/src/gmail-ai-classifier/tests/performance-scalability.test.js +++ b/src/gmail-ai-classifier/tests/performance-scalability.test.js @@ -2,19 +2,24 @@ * Performance and Scalability Benchmark Test Suite for Gmail AI Classifier & GitHub Sync */ -const { processThreadBatch } = require('../src/index.js') +const { + insertEntryIntoLogSection, + appendMarkdownEntryToGitHubRepo, + processThreadBatch, + classifyWithGemini, +} = require('../src/index.js') describe('Performance and Scalability Benchmarks', () => { test('Section-Aware Insertion Benchmark: Large 10,000 Line Document', () => { - // Generate a 10,000 line mock markdown document string - const lines = [] - lines.push('# Master Index Note') - lines.push('## 1. Executive Summary') - lines.push('- Active status ok') - lines.push('## 2. Key References') - lines.push('| Topic | Asset |') - lines.push('## 3. Ingested Activity Logs') - lines.push('<details open><summary>Logs</summary>') + const lines = [ + '# Master Index Note', + '## 1. Executive Summary', + '- Active status ok', + '## 2. Key References', + '| Topic | Asset |', + '## 3. Ingested Activity Logs', + '<details open><summary>Logs</summary>', + ] for (let i = 1; i <= 10000; i++) { lines.push( @@ -28,69 +33,75 @@ describe('Performance and Scalability Benchmarks', () => { '### 2026-07-28 — Benchmark Test Entry\n- **Source**: Benchmark Test' const startTime = Date.now() - - // Simulate section insertion logic - const detailsMarker = '</details>' - const detailsIndex = largeDocContent.indexOf(detailsMarker) - const updatedContent = - largeDocContent.substring(0, detailsIndex) + - newEntry + - '\n' + - largeDocContent.substring(detailsIndex) - + const updatedContent = insertEntryIntoLogSection(largeDocContent, newEntry) const elapsedTimeMs = Date.now() - startTime expect(updatedContent).toContain('Benchmark Test Entry') expect(updatedContent.length).toBeGreaterThan(largeDocContent.length) - expect(elapsedTimeMs).toBeLessThan(50) // Must execute under 50ms for 10,000 lines + expect(elapsedTimeMs).toBeLessThan(200) }) - test('SHA Collision & Retry Logic Stress Test', () => { + test('SHA Collision & Retry Logic Stress Test via appendMarkdownEntryToGitHubRepo', () => { let attemptCount = 0 - - // Simulate a function that fails twice with 409 Conflict before succeeding on attempt 3 - const simulateCommitWithConflict = () => { - attemptCount++ - if (attemptCount < 3) { - return { statusCode: 409, message: 'SHA Collision Conflict' } - } - return { statusCode: 200, message: 'Success' } + const mockProps = { + getProperty: (k) => { + if (k === 'GITHUB_PAT') return 'ghp_bench' + if (k === 'GITHUB_REPO_OWNER') return 'bench-owner' + if (k === 'GITHUB_REPO_NAME') return 'bench-repo' + return '' + }, + } + const existingBase64 = Utilities.base64Encode( + Utilities.newBlob( + '# Doc\n<details open><summary>Logs</summary>\n</details>' + ).getBytes() + ) + const mockUrlFetch = { + fetch: (_url, options) => { + if (options.method === 'get') { + return { + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + sha: 'sha_' + attemptCount, + content: existingBase64, + }), + } + } + attemptCount++ + if (attemptCount < 3) { + return { + getResponseCode: () => 409, + getContentText: () => JSON.stringify({ message: 'Conflict' }), + } + } + return { + getResponseCode: () => 200, + getContentText: () => JSON.stringify({ commit: { sha: 'ok' } }), + } + }, } - let finalResult = null - for (let i = 1; i <= 3; i++) { - const res = simulateCommitWithConflict() - if (res.statusCode === 200) { - finalResult = res - break + const success = appendMarkdownEntryToGitHubRepo( + '01_Household/index.md', + '### 2026-07-28 — Retry Entry', + 'feat(ingestion): Retry Test', + { + PropertiesService: { getScriptProperties: () => mockProps }, + UrlFetchApp: mockUrlFetch, + Utilities: { + ...Utilities, + sleep: () => {}, + }, } - } + ) + expect(success).toBe(true) expect(attemptCount).toBe(3) - expect(finalResult.statusCode).toBe(200) }) - test('Batch Processing Throughput Benchmark', () => { - const BATCH_SIZE = 100 - const classification = { - canonical_label: '04_Family_Health/Bowens', - confidence: 0.98, - reasoning: 'benchmark classification', - } - const geminiResponse = { - getResponseCode: () => 200, - getContentText: () => - JSON.stringify({ - candidates: [ - { content: { parts: [{ text: JSON.stringify(classification) }] } }, - ], - }), - } - const makeLabel = (name) => ({ - getName: () => name, - getId: () => `label-${name}`, - }) - const threads = Array.from({ length: BATCH_SIZE }, (_, i) => ({ + test('Batch Processing Throughput Benchmark via processThreadBatch', () => { + const threads = Array.from({ length: 100 }, (_, i) => ({ getId: () => `msg_${i}`, getMessages: () => [ { @@ -99,21 +110,47 @@ describe('Performance and Scalability Benchmarks', () => { getPlainBody: () => `Plain body text snippet for email #${i}`, }, ], - addLabel: jest.fn(), + addLabel: () => {}, })) + const config = { - modelEndpoint: 'https://example.com/api', - geminiApiKey: 'test-key', + modelEndpoint: 'https://example.com/gemini', + geminiApiKey: 'bench-key', processedLabel: 'Processed', autoFilterConfidenceThreshold: 0.99, - canonicalDomains: ['04_Family_Health/Bowens'], + canonicalDomains: ['04_Family_Health'], } const services = { GmailApp: { - getUserLabelByName: jest.fn((name) => makeLabel(name)), - createLabel: jest.fn((name) => makeLabel(name)), + getUserLabelByName: (name) => ({ + getName: () => name, + getId: () => name, + }), + createLabel: (name) => ({ getName: () => name, getId: () => name }), + }, + UrlFetchApp: { + fetch: () => ({ + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { + text: JSON.stringify({ + canonical_label: '04_Family_Health', + confidence: 0.98, + reasoning: 'benchmark', + }), + }, + ], + }, + }, + ], + }), + }), }, - UrlFetchApp: { fetch: jest.fn(() => geminiResponse) }, Gmail: null, } @@ -121,12 +158,79 @@ describe('Performance and Scalability Benchmarks', () => { const processed = processThreadBatch(threads, config, services) const duration = Date.now() - startTime - expect(processed).toHaveLength(BATCH_SIZE) - processed.forEach((result, i) => { - expect(result.threadId).toBe(`msg_${i}`) - expect(result.status).toBe('classified') - expect(result.label).toBe(classification.canonical_label) - }) - expect(duration).toBeLessThan(100) // 100 items benchmarked under 100ms + expect(processed.length).toBe(100) + expect(processed.every((r) => r.status === 'classified')).toBe(true) + expect(processed.every((r) => r.label === '04_Family_Health')).toBe(true) + expect(duration).toBeLessThan(500) + }) + + test('Classification Caching Throughput & Latency Benchmark via classifyWithGemini', () => { + const memoryStore = new Map() + const mockCache = { + get: (k) => memoryStore.get(k) || null, + put: (k, v) => memoryStore.set(k, v), + } + + const classification = { + canonicalDomain: '02_Finance_Legal', + subLabel: 'Finance/Purchases', + category: 'Updates', + action: 'archive', + confidence: 0.98, + } + + const mockUrlFetch = { + fetch: jest.fn(() => ({ + getResponseCode: () => 200, + getContentText: () => + JSON.stringify({ + candidates: [ + { + content: { + parts: [{ text: JSON.stringify(classification) }], + }, + }, + ], + }), + })), + } + + const config = { + geminiApiKey: 'bench-key', + canonicalDomains: ['02_Finance_Legal'], + } + const services = { + CacheService: mockCache, + UrlFetchApp: mockUrlFetch, + } + + // First call seeds cache via classifyWithGemini + const firstRes = classifyWithGemini( + 'service@paypal.com', + 'Receipt #100', + 'Payment of $10.00', + config, + services + ) + expect(firstRes).toEqual(classification) + expect(mockUrlFetch.fetch).toHaveBeenCalledTimes(1) + + // 250 cached retrieval iterations through real classifyWithGemini + const startTime = Date.now() + let lastRes = null + for (let i = 0; i < 250; i++) { + lastRes = classifyWithGemini( + 'service@paypal.com', + 'Receipt #100', + 'Payment of $10.00', + config, + services + ) + } + const elapsed = Date.now() - startTime + + expect(lastRes.canonicalDomain).toBe('02_Finance_Legal') + expect(mockUrlFetch.fetch).toHaveBeenCalledTimes(1) + expect(elapsed).toBeLessThan(500) }) }) diff --git a/test-utils/setup.js b/test-utils/setup.js index 629fbc60..1cc495dd 100644 --- a/test-utils/setup.js +++ b/test-utils/setup.js @@ -20,16 +20,21 @@ global.Utilities = { }, // Provide a simple sleep stub used in code sleep: (ms) => {}, - // Mock SHA-256 hash computation + // Mock digest hash computation (MD5 and SHA-256) computeDigest: (algorithm, bytes) => { // Simple deterministic hash for testing // Convert bytes to a string and create a fake hash const crypto = require('node:crypto') - const hash = crypto.createHash('sha256').update(Buffer.from(bytes)).digest() + const algo = + algorithm && String(algorithm).toUpperCase().includes('SHA') + ? 'sha256' + : 'md5' + const hash = crypto.createHash(algo).update(Buffer.from(bytes)).digest() // Return as array of numbers (like GAS does) return Array.from(hash) }, DigestAlgorithm: { + MD5: 'MD5', SHA_256: 'SHA_256', }, Charset: {