this repo has no description
1// Apps Script wrapper - imports the main classifier
2// This file is bundled into a single .gs file
3
4import { classifyEmail } from "../classifier";
5import type { EmailInput, ClassificationResult } from "../types";
6
7// Configuration
8const AUTO_LABEL_NAME = "College/Auto";
9const FILTERED_LABEL_NAME = "College/Filtered";
10const APPROVED_LABEL_NAME = "College";
11const DRY_RUN = true;
12
13const MAX_THREADS_PER_RUN = 75;
14const MAX_EXECUTION_TIME_MS = 4.5 * 60 * 1000;
15const GMAIL_BATCH_SIZE = 20;
16
17// Declare global for Apps Script
18declare const GmailApp: any;
19declare const Logger: any;
20declare const Utilities: any;
21declare const ScriptApp: any;
22
23// Main entry points
24function ensureLabels(): void {
25 getOrCreateLabel(AUTO_LABEL_NAME);
26 getOrCreateLabel(FILTERED_LABEL_NAME);
27 getOrCreateLabel(APPROVED_LABEL_NAME);
28 Logger.log(`Labels ensured: ${AUTO_LABEL_NAME}, ${FILTERED_LABEL_NAME}, ${APPROVED_LABEL_NAME}`);
29}
30
31function runTriage(): void {
32 const startTime = Date.now();
33 const autoLabel = getOrCreateLabel(AUTO_LABEL_NAME);
34 const filteredLabel = getOrCreateLabel(FILTERED_LABEL_NAME);
35 const approvedLabel = getOrCreateLabel(APPROVED_LABEL_NAME);
36
37 const threads = autoLabel.getThreads(0, MAX_THREADS_PER_RUN);
38 if (!threads.length) {
39 Logger.log("No threads under College/Auto.");
40 return;
41 }
42
43 Logger.log(`Processing ${threads.length} threads`);
44
45 let stats = {
46 wouldInbox: 0,
47 wouldFiltered: 0,
48 didInbox: 0,
49 didFiltered: 0,
50 errors: 0,
51 skipped: 0
52 };
53
54 for (let i = 0; i < threads.length; i++) {
55 const elapsed = Date.now() - startTime;
56 if (elapsed > MAX_EXECUTION_TIME_MS) {
57 Logger.log(`Time limit reached. Processed ${i}/${threads.length}`);
58 stats.skipped = threads.length - i;
59 break;
60 }
61
62 const thread = threads[i];
63
64 try {
65 processThread(thread, autoLabel, approvedLabel, filteredLabel, stats);
66 } catch (e) {
67 Logger.log(`ERROR: ${e}. FAIL-SAFE: Moving to inbox.`);
68 stats.errors += 1;
69
70 if (!DRY_RUN) {
71 thread.removeLabel(autoLabel);
72 thread.removeLabel(filteredLabel);
73 thread.moveToInbox();
74 stats.didInbox += 1;
75 } else {
76 stats.wouldInbox += 1;
77 }
78 }
79
80 if ((i + 1) % GMAIL_BATCH_SIZE === 0) {
81 Utilities.sleep(100);
82 }
83 }
84
85 const totalTime = ((Date.now() - startTime) / 1000).toFixed(2);
86 Logger.log(`Summary: Inbox=${stats.wouldInbox}/${stats.didInbox}, Filtered=${stats.wouldFiltered}/${stats.didFiltered}, Errors=${stats.errors}, Time=${totalTime}s`);
87}
88
89function processThread(
90 thread: any,
91 autoLabel: any,
92 approvedLabel: any,
93 filteredLabel: any,
94 stats: any
95): void {
96 const msg = thread.getMessages()[thread.getMessages().length - 1];
97 if (!msg) throw new Error("No messages in thread");
98
99 const meta: EmailInput = {
100 subject: safeStr(msg.getSubject()),
101 body: safeStr(msg.getPlainBody(), 10000),
102 from: safeStr(msg.getFrom()),
103 };
104
105 if (!meta.subject && !meta.body) {
106 Logger.log(`WARNING: No content. FAIL-SAFE: Moving to inbox.`);
107 applyInboxAction(thread, autoLabel, approvedLabel, filteredLabel, stats, "no content");
108 return;
109 }
110
111 const result = classifyEmail(meta);
112
113 Logger.log(`[${thread.getId()}] Relevant=${result.pertains} Confidence=${result.confidence} Reason="${result.reason}"`);
114
115 if (result.pertains) {
116 applyInboxAction(thread, autoLabel, approvedLabel, filteredLabel, stats, result.reason);
117 } else {
118 applyFilteredAction(thread, autoLabel, filteredLabel, stats, result.reason);
119 }
120}
121
122function applyInboxAction(
123 thread: any,
124 autoLabel: any,
125 approvedLabel: any,
126 filteredLabel: any,
127 stats: any,
128 reason: string
129): void {
130 if (DRY_RUN) {
131 stats.wouldInbox += 1;
132 Logger.log(` DRY_RUN: Would move to Inbox (${reason})`);
133 } else {
134 thread.removeLabel(autoLabel);
135 thread.removeLabel(filteredLabel);
136 thread.addLabel(approvedLabel);
137 thread.moveToInbox();
138 stats.didInbox += 1;
139 Logger.log(` Applied: Moved to Inbox (${reason})`);
140 }
141}
142
143function applyFilteredAction(
144 thread: any,
145 autoLabel: any,
146 filteredLabel: any,
147 stats: any,
148 reason: string
149): void {
150 if (DRY_RUN) {
151 stats.wouldFiltered += 1;
152 Logger.log(` DRY_RUN: Would filter (${reason})`);
153 } else {
154 thread.removeLabel(autoLabel);
155 thread.addLabel(filteredLabel);
156 if (thread.isInInbox()) thread.moveToArchive();
157 stats.didFiltered += 1;
158 Logger.log(` Applied: Filtered (${reason})`);
159 }
160}
161
162// Utilities
163function getOrCreateLabel(name: string): any {
164 return GmailApp.getUserLabelByName(name) || GmailApp.createLabel(name);
165}
166
167function safeStr(s: string | null, maxLen?: number): string {
168 if (s === null || s === undefined) return "";
169 const str = s.toString().trim();
170 if (maxLen && str.length > maxLen) return str.slice(0, maxLen);
171 return str;
172}
173
174function setupTriggers(): void {
175 // Delete existing triggers
176 const triggers = ScriptApp.getProjectTriggers();
177 for (let i = 0; i < triggers.length; i++) {
178 if (triggers[i].getHandlerFunction() === "runTriage") {
179 ScriptApp.deleteTrigger(triggers[i]);
180 }
181 }
182
183 // Create new trigger
184 ScriptApp.newTrigger("runTriage")
185 .timeBased()
186 .everyMinutes(10)
187 .create();
188
189 Logger.log("Trigger created: runTriage every 10 minutes");
190}
191
192// Export for Apps Script global scope - these become top-level functions
193// Note: The bundler needs to be configured to expose these properly
194
195// Make sure functions are not tree-shaken by referencing them
196export { ensureLabels, runTriage, setupTriggers };