inboxd 1.0.8 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/inbox-assistant/SKILL.md +421 -97
- package/CLAUDE.md +60 -5
- package/README.md +47 -8
- package/package.json +3 -2
- package/scripts/postinstall.js +83 -0
- package/src/cli.js +460 -46
- package/src/gmail-monitor.js +109 -0
- package/src/skill-installer.js +282 -0
- package/tests/filter.test.js +200 -0
- package/tests/group-by-sender.test.js +141 -0
package/src/gmail-monitor.js
CHANGED
|
@@ -181,10 +181,119 @@ async function untrashEmails(account, messageIds) {
|
|
|
181
181
|
return results;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Marks emails as read by removing the UNREAD label
|
|
186
|
+
* @param {string} account - Account name
|
|
187
|
+
* @param {Array<string>} messageIds - Array of message IDs to mark as read
|
|
188
|
+
* @returns {Array<{id: string, success: boolean, error?: string}>} Results for each message
|
|
189
|
+
*/
|
|
190
|
+
async function markAsRead(account, messageIds) {
|
|
191
|
+
const gmail = await getGmailClient(account);
|
|
192
|
+
const results = [];
|
|
193
|
+
|
|
194
|
+
for (const id of messageIds) {
|
|
195
|
+
try {
|
|
196
|
+
await withRetry(() => gmail.users.messages.modify({
|
|
197
|
+
userId: 'me',
|
|
198
|
+
id: id,
|
|
199
|
+
requestBody: {
|
|
200
|
+
removeLabelIds: ['UNREAD'],
|
|
201
|
+
},
|
|
202
|
+
}));
|
|
203
|
+
results.push({ id, success: true });
|
|
204
|
+
} catch (err) {
|
|
205
|
+
results.push({ id, success: false, error: err.message });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return results;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Archives emails by removing the INBOX label
|
|
214
|
+
* @param {string} account - Account name
|
|
215
|
+
* @param {Array<string>} messageIds - Array of message IDs to archive
|
|
216
|
+
* @returns {Array<{id: string, success: boolean, error?: string}>} Results for each message
|
|
217
|
+
*/
|
|
218
|
+
async function archiveEmails(account, messageIds) {
|
|
219
|
+
const gmail = await getGmailClient(account);
|
|
220
|
+
const results = [];
|
|
221
|
+
|
|
222
|
+
for (const id of messageIds) {
|
|
223
|
+
try {
|
|
224
|
+
await withRetry(() => gmail.users.messages.modify({
|
|
225
|
+
userId: 'me',
|
|
226
|
+
id: id,
|
|
227
|
+
requestBody: {
|
|
228
|
+
removeLabelIds: ['INBOX'],
|
|
229
|
+
},
|
|
230
|
+
}));
|
|
231
|
+
results.push({ id, success: true });
|
|
232
|
+
} catch (err) {
|
|
233
|
+
results.push({ id, success: false, error: err.message });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return results;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Extracts the domain from a From header value
|
|
242
|
+
* @param {string} from - e.g., "Sender Name <sender@example.com>" or "sender@example.com"
|
|
243
|
+
* @returns {string} Normalized domain (e.g., "example.com") or lowercased from if no domain found
|
|
244
|
+
*/
|
|
245
|
+
function extractSenderDomain(from) {
|
|
246
|
+
if (!from) return '';
|
|
247
|
+
// Match email in angle brackets or bare email
|
|
248
|
+
const emailMatch = from.match(/<([^>]+)>/) || from.match(/([^\s]+@[^\s]+)/);
|
|
249
|
+
if (emailMatch) {
|
|
250
|
+
const email = emailMatch[1];
|
|
251
|
+
const domain = email.split('@')[1];
|
|
252
|
+
return domain ? domain.toLowerCase() : email.toLowerCase();
|
|
253
|
+
}
|
|
254
|
+
return from.toLowerCase();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Groups emails by sender domain
|
|
259
|
+
* @param {Array<Object>} emails - Array of email objects with from, id, subject, date, account
|
|
260
|
+
* @returns {{groups: Array<{sender: string, senderDisplay: string, count: number, emails: Array}>, totalCount: number}}
|
|
261
|
+
*/
|
|
262
|
+
function groupEmailsBySender(emails) {
|
|
263
|
+
const groups = {};
|
|
264
|
+
|
|
265
|
+
for (const email of emails) {
|
|
266
|
+
const domain = extractSenderDomain(email.from);
|
|
267
|
+
if (!groups[domain]) {
|
|
268
|
+
groups[domain] = {
|
|
269
|
+
sender: domain,
|
|
270
|
+
senderDisplay: email.from,
|
|
271
|
+
count: 0,
|
|
272
|
+
emails: [],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
groups[domain].count++;
|
|
276
|
+
groups[domain].emails.push({
|
|
277
|
+
id: email.id,
|
|
278
|
+
subject: email.subject,
|
|
279
|
+
date: email.date,
|
|
280
|
+
account: email.account,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Convert to array and sort by count descending
|
|
285
|
+
const groupArray = Object.values(groups).sort((a, b) => b.count - a.count);
|
|
286
|
+
return { groups: groupArray, totalCount: emails.length };
|
|
287
|
+
}
|
|
288
|
+
|
|
184
289
|
module.exports = {
|
|
185
290
|
getUnreadEmails,
|
|
186
291
|
getEmailCount,
|
|
187
292
|
trashEmails,
|
|
188
293
|
getEmailById,
|
|
189
294
|
untrashEmails,
|
|
295
|
+
markAsRead,
|
|
296
|
+
archiveEmails,
|
|
297
|
+
extractSenderDomain,
|
|
298
|
+
groupEmailsBySender,
|
|
190
299
|
};
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill Installer - Copies the inbox-assistant skill to ~/.claude/skills/
|
|
3
|
+
* Enables AI agents (like Claude Code) to use inboxd effectively.
|
|
4
|
+
*
|
|
5
|
+
* Safety features:
|
|
6
|
+
* - Source marker: Only manages skills with `source: inboxd` in front matter
|
|
7
|
+
* - Content hash: Detects changes without version numbers
|
|
8
|
+
* - Backup: Creates .backup before replacing modified files
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
|
|
16
|
+
const SKILL_NAME = 'inbox-assistant';
|
|
17
|
+
const SOURCE_MARKER = 'inboxd';
|
|
18
|
+
const SKILL_SOURCE_DIR = path.join(__dirname, '..', '.claude', 'skills', SKILL_NAME);
|
|
19
|
+
const SKILL_DEST_DIR = path.join(os.homedir(), '.claude', 'skills', SKILL_NAME);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compute MD5 hash of file content
|
|
23
|
+
* @param {string} filePath - Path to file
|
|
24
|
+
* @returns {string|null} Hash string or null if file doesn't exist
|
|
25
|
+
*/
|
|
26
|
+
function getFileHash(filePath) {
|
|
27
|
+
try {
|
|
28
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
29
|
+
return crypto.createHash('md5').update(content).digest('hex');
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract the source field from SKILL.md front matter
|
|
37
|
+
* @param {string} skillPath - Path to SKILL.md
|
|
38
|
+
* @returns {string|null} Source string or null if not found
|
|
39
|
+
*/
|
|
40
|
+
function getSkillSource(skillPath) {
|
|
41
|
+
try {
|
|
42
|
+
const content = fs.readFileSync(skillPath, 'utf8');
|
|
43
|
+
const match = content.match(/^---[\s\S]*?source:\s*["']?([^"'\n]+)["']?[\s\S]*?---/m);
|
|
44
|
+
return match ? match[1].trim() : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create a backup of the skill file
|
|
52
|
+
* @param {string} skillDir - Directory containing SKILL.md
|
|
53
|
+
* @returns {{ success: boolean, tempPath: string|null, originalHash: string|null }} Backup result
|
|
54
|
+
*/
|
|
55
|
+
function createBackup(skillDir) {
|
|
56
|
+
const skillPath = path.join(skillDir, 'SKILL.md');
|
|
57
|
+
// Backup to parent directory to survive directory deletion
|
|
58
|
+
const backupPath = path.join(path.dirname(skillDir), `${SKILL_NAME}-SKILL.md.backup`);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
if (fs.existsSync(skillPath)) {
|
|
62
|
+
// Get hash of original before backup (for verification)
|
|
63
|
+
const originalHash = getFileHash(skillPath);
|
|
64
|
+
fs.copyFileSync(skillPath, backupPath);
|
|
65
|
+
|
|
66
|
+
// Verify backup was created with correct content
|
|
67
|
+
const backupHash = getFileHash(backupPath);
|
|
68
|
+
if (backupHash === originalHash) {
|
|
69
|
+
return { success: true, tempPath: backupPath, originalHash };
|
|
70
|
+
}
|
|
71
|
+
// Backup hash doesn't match - something went wrong
|
|
72
|
+
return { success: false, tempPath: null, originalHash: null };
|
|
73
|
+
}
|
|
74
|
+
return { success: false, tempPath: null, originalHash: null };
|
|
75
|
+
} catch {
|
|
76
|
+
return { success: false, tempPath: null, originalHash: null };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Move backup file into the skill directory after installation
|
|
82
|
+
* @param {string} tempBackupPath - Path to temporary backup in parent directory
|
|
83
|
+
* @param {string} skillDir - Skill directory to move backup into
|
|
84
|
+
* @returns {string|null} Final backup path or null if failed
|
|
85
|
+
*/
|
|
86
|
+
function moveBackupToSkillDir(tempBackupPath, skillDir) {
|
|
87
|
+
if (!tempBackupPath || !fs.existsSync(tempBackupPath)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const finalBackupPath = path.join(skillDir, 'SKILL.md.backup');
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
fs.renameSync(tempBackupPath, finalBackupPath);
|
|
95
|
+
return finalBackupPath;
|
|
96
|
+
} catch {
|
|
97
|
+
// If rename fails (cross-device), try copy + delete
|
|
98
|
+
try {
|
|
99
|
+
fs.copyFileSync(tempBackupPath, finalBackupPath);
|
|
100
|
+
fs.unlinkSync(tempBackupPath);
|
|
101
|
+
return finalBackupPath;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check if the skill is already installed and get its status
|
|
110
|
+
* @returns {{ installed: boolean, currentHash: string|null, sourceHash: string|null, isOurs: boolean, source: string|null }}
|
|
111
|
+
*/
|
|
112
|
+
function getSkillStatus() {
|
|
113
|
+
const destSkillMd = path.join(SKILL_DEST_DIR, 'SKILL.md');
|
|
114
|
+
const sourceSkillMd = path.join(SKILL_SOURCE_DIR, 'SKILL.md');
|
|
115
|
+
|
|
116
|
+
const installed = fs.existsSync(destSkillMd);
|
|
117
|
+
const currentHash = installed ? getFileHash(destSkillMd) : null;
|
|
118
|
+
const sourceHash = getFileHash(sourceSkillMd);
|
|
119
|
+
const source = installed ? getSkillSource(destSkillMd) : null;
|
|
120
|
+
const isOurs = source === SOURCE_MARKER;
|
|
121
|
+
|
|
122
|
+
return { installed, currentHash, sourceHash, isOurs, source };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Check if an update is available
|
|
127
|
+
* @returns {{ updateAvailable: boolean, isOurs: boolean, hashMismatch: boolean }}
|
|
128
|
+
*/
|
|
129
|
+
function checkForUpdate() {
|
|
130
|
+
const status = getSkillStatus();
|
|
131
|
+
|
|
132
|
+
if (!status.installed) {
|
|
133
|
+
return { updateAvailable: false, isOurs: false, hashMismatch: false };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const hashMismatch = status.currentHash !== status.sourceHash;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
updateAvailable: status.isOurs && hashMismatch,
|
|
140
|
+
isOurs: status.isOurs,
|
|
141
|
+
hashMismatch
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Copy directory recursively
|
|
147
|
+
* @param {string} src - Source directory
|
|
148
|
+
* @param {string} dest - Destination directory
|
|
149
|
+
*/
|
|
150
|
+
function copyDirSync(src, dest) {
|
|
151
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
152
|
+
|
|
153
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
154
|
+
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
const srcPath = path.join(src, entry.name);
|
|
157
|
+
const destPath = path.join(dest, entry.name);
|
|
158
|
+
|
|
159
|
+
if (entry.isDirectory()) {
|
|
160
|
+
copyDirSync(srcPath, destPath);
|
|
161
|
+
} else {
|
|
162
|
+
fs.copyFileSync(srcPath, destPath);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Install or update the skill
|
|
169
|
+
* @param {{ force?: boolean }} options - Installation options
|
|
170
|
+
* @returns {{ success: boolean, action: 'installed'|'updated'|'unchanged'|'skipped', reason?: string, backedUp?: boolean, backupPath?: string, path: string }}
|
|
171
|
+
*/
|
|
172
|
+
function installSkill(options = {}) {
|
|
173
|
+
const { force = false } = options;
|
|
174
|
+
|
|
175
|
+
// Ensure source exists
|
|
176
|
+
if (!fs.existsSync(SKILL_SOURCE_DIR)) {
|
|
177
|
+
throw new Error(`Skill source not found at ${SKILL_SOURCE_DIR}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const status = getSkillStatus();
|
|
181
|
+
const updateInfo = checkForUpdate();
|
|
182
|
+
|
|
183
|
+
// Check ownership if skill already exists
|
|
184
|
+
if (status.installed && !status.isOurs && !force) {
|
|
185
|
+
return {
|
|
186
|
+
success: false,
|
|
187
|
+
action: 'skipped',
|
|
188
|
+
reason: 'not_owned',
|
|
189
|
+
path: SKILL_DEST_DIR
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Check if already up-to-date
|
|
194
|
+
if (status.installed && status.isOurs && !updateInfo.hashMismatch) {
|
|
195
|
+
return {
|
|
196
|
+
success: true,
|
|
197
|
+
action: 'unchanged',
|
|
198
|
+
path: SKILL_DEST_DIR
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Ensure parent directory exists
|
|
203
|
+
const skillsDir = path.dirname(SKILL_DEST_DIR);
|
|
204
|
+
fs.mkdirSync(skillsDir, { recursive: true });
|
|
205
|
+
|
|
206
|
+
// Backup if replacing existing (user may have modified)
|
|
207
|
+
let backedUp = false;
|
|
208
|
+
let backupPath = null;
|
|
209
|
+
let backupResult = null;
|
|
210
|
+
if (status.installed) {
|
|
211
|
+
backupResult = createBackup(SKILL_DEST_DIR);
|
|
212
|
+
|
|
213
|
+
if (!backupResult.success) {
|
|
214
|
+
// Backup failed - don't proceed without protecting user's data
|
|
215
|
+
return {
|
|
216
|
+
success: false,
|
|
217
|
+
action: 'skipped',
|
|
218
|
+
reason: 'backup_failed',
|
|
219
|
+
path: SKILL_DEST_DIR
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Remove existing for clean update
|
|
224
|
+
fs.rmSync(SKILL_DEST_DIR, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Copy the skill directory
|
|
228
|
+
copyDirSync(SKILL_SOURCE_DIR, SKILL_DEST_DIR);
|
|
229
|
+
|
|
230
|
+
// Move backup into the new skill directory
|
|
231
|
+
if (backupResult && backupResult.tempPath) {
|
|
232
|
+
backupPath = moveBackupToSkillDir(backupResult.tempPath, SKILL_DEST_DIR);
|
|
233
|
+
backedUp = !!backupPath;
|
|
234
|
+
|
|
235
|
+
// Verify the backup still has the original content after move
|
|
236
|
+
if (backedUp && backupResult.originalHash) {
|
|
237
|
+
const movedBackupHash = getFileHash(backupPath);
|
|
238
|
+
if (movedBackupHash !== backupResult.originalHash) {
|
|
239
|
+
// Something went wrong during move - backup is corrupted
|
|
240
|
+
backedUp = false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const action = status.installed ? 'updated' : 'installed';
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
success: true,
|
|
249
|
+
action,
|
|
250
|
+
backedUp,
|
|
251
|
+
backupPath,
|
|
252
|
+
path: SKILL_DEST_DIR
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Uninstall the skill
|
|
258
|
+
* @returns {{ success: boolean, existed: boolean }}
|
|
259
|
+
*/
|
|
260
|
+
function uninstallSkill() {
|
|
261
|
+
const existed = fs.existsSync(SKILL_DEST_DIR);
|
|
262
|
+
|
|
263
|
+
if (existed) {
|
|
264
|
+
fs.rmSync(SKILL_DEST_DIR, { recursive: true, force: true });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { success: true, existed };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
SKILL_NAME,
|
|
272
|
+
SOURCE_MARKER,
|
|
273
|
+
SKILL_SOURCE_DIR,
|
|
274
|
+
SKILL_DEST_DIR,
|
|
275
|
+
getFileHash,
|
|
276
|
+
getSkillSource,
|
|
277
|
+
createBackup,
|
|
278
|
+
getSkillStatus,
|
|
279
|
+
checkForUpdate,
|
|
280
|
+
installSkill,
|
|
281
|
+
uninstallSkill
|
|
282
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Test the email filtering logic used in inbox delete command
|
|
4
|
+
// We test the logic patterns without importing the actual module
|
|
5
|
+
|
|
6
|
+
describe('Email Filtering Logic', () => {
|
|
7
|
+
const testEmails = [
|
|
8
|
+
{ id: '1', from: 'LinkedIn Jobs <jobs@linkedin.com>', subject: 'New jobs for you', account: 'personal' },
|
|
9
|
+
{ id: '2', from: 'Jules Bot <jules@company.com>', subject: 'PR #42 ready for review', account: 'work' },
|
|
10
|
+
{ id: '3', from: 'newsletter@techcrunch.com', subject: 'TechCrunch Weekly Digest', account: 'personal' },
|
|
11
|
+
{ id: '4', from: 'Security Alert <security@bank.com>', subject: 'Unusual login detected', account: 'personal' },
|
|
12
|
+
{ id: '5', from: 'LinkedIn <notifications@linkedin.com>', subject: 'You have 3 new messages', account: 'work' },
|
|
13
|
+
{ id: '6', from: 'GitHub <noreply@github.com>', subject: 'PR Review requested', account: 'work' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Filter function matching the implementation in cli.js
|
|
18
|
+
*/
|
|
19
|
+
function filterEmails(emails, senderPattern, matchPattern) {
|
|
20
|
+
return emails.filter(e => {
|
|
21
|
+
const matchesSender = !senderPattern ||
|
|
22
|
+
e.from.toLowerCase().includes(senderPattern.toLowerCase());
|
|
23
|
+
const matchesSubject = !matchPattern ||
|
|
24
|
+
e.subject.toLowerCase().includes(matchPattern.toLowerCase());
|
|
25
|
+
return matchesSender && matchesSubject;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('Sender filtering', () => {
|
|
30
|
+
it('should match case-insensitive substring in from field', () => {
|
|
31
|
+
const filtered = filterEmails(testEmails, 'linkedin', null);
|
|
32
|
+
expect(filtered).toHaveLength(2);
|
|
33
|
+
expect(filtered.map(e => e.id)).toEqual(['1', '5']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should match partial sender names', () => {
|
|
37
|
+
const filtered = filterEmails(testEmails, 'jules', null);
|
|
38
|
+
expect(filtered).toHaveLength(1);
|
|
39
|
+
expect(filtered[0].id).toBe('2');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should match email domains', () => {
|
|
43
|
+
const filtered = filterEmails(testEmails, '@linkedin.com', null);
|
|
44
|
+
expect(filtered).toHaveLength(2);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should be case insensitive', () => {
|
|
48
|
+
const filtered = filterEmails(testEmails, 'LINKEDIN', null);
|
|
49
|
+
expect(filtered).toHaveLength(2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should match partial email addresses', () => {
|
|
53
|
+
const filtered = filterEmails(testEmails, 'noreply', null);
|
|
54
|
+
expect(filtered).toHaveLength(1);
|
|
55
|
+
expect(filtered[0].id).toBe('6');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('Subject filtering', () => {
|
|
60
|
+
it('should match case-insensitive substring in subject', () => {
|
|
61
|
+
const filtered = filterEmails(testEmails, null, 'login');
|
|
62
|
+
expect(filtered).toHaveLength(1);
|
|
63
|
+
expect(filtered[0].id).toBe('4');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should match partial subject phrases', () => {
|
|
67
|
+
const filtered = filterEmails(testEmails, null, 'weekly');
|
|
68
|
+
expect(filtered).toHaveLength(1);
|
|
69
|
+
expect(filtered[0].id).toBe('3');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should match PR-related subjects', () => {
|
|
73
|
+
const filtered = filterEmails(testEmails, null, 'PR');
|
|
74
|
+
expect(filtered).toHaveLength(2);
|
|
75
|
+
expect(filtered.map(e => e.id).sort()).toEqual(['2', '6']);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('Combined filtering (AND logic)', () => {
|
|
80
|
+
it('should require both sender AND subject to match', () => {
|
|
81
|
+
const filtered = filterEmails(testEmails, 'linkedin', 'jobs');
|
|
82
|
+
expect(filtered).toHaveLength(1);
|
|
83
|
+
expect(filtered[0].id).toBe('1');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should return empty when no emails match both criteria', () => {
|
|
87
|
+
const filtered = filterEmails(testEmails, 'linkedin', 'security');
|
|
88
|
+
expect(filtered).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should work with overlapping patterns', () => {
|
|
92
|
+
const filtered = filterEmails(testEmails, 'github', 'review');
|
|
93
|
+
expect(filtered).toHaveLength(1);
|
|
94
|
+
expect(filtered[0].id).toBe('6');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('No filter (passthrough)', () => {
|
|
99
|
+
it('should return all emails when no filters specified', () => {
|
|
100
|
+
const filtered = filterEmails(testEmails, null, null);
|
|
101
|
+
expect(filtered).toHaveLength(6);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should return all emails with empty string filters', () => {
|
|
105
|
+
const filtered = filterEmails(testEmails, '', '');
|
|
106
|
+
expect(filtered).toHaveLength(6);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('Safety Limit Logic', () => {
|
|
112
|
+
it('should enforce default limit of 50', () => {
|
|
113
|
+
const manyEmails = Array.from({ length: 100 }, (_, i) => ({
|
|
114
|
+
id: `msg${i}`,
|
|
115
|
+
from: 'spam@example.com',
|
|
116
|
+
subject: `Spam ${i}`,
|
|
117
|
+
}));
|
|
118
|
+
const limit = 50;
|
|
119
|
+
const limited = manyEmails.slice(0, limit);
|
|
120
|
+
expect(limited).toHaveLength(50);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should respect custom limit', () => {
|
|
124
|
+
const manyEmails = Array.from({ length: 100 }, (_, i) => ({
|
|
125
|
+
id: `msg${i}`,
|
|
126
|
+
from: 'spam@example.com',
|
|
127
|
+
subject: `Spam ${i}`,
|
|
128
|
+
}));
|
|
129
|
+
const limit = 25;
|
|
130
|
+
const limited = manyEmails.slice(0, limit);
|
|
131
|
+
expect(limited).toHaveLength(25);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should not truncate when under limit', () => {
|
|
135
|
+
const emails = Array.from({ length: 10 }, (_, i) => ({
|
|
136
|
+
id: `msg${i}`,
|
|
137
|
+
from: 'test@example.com',
|
|
138
|
+
subject: `Test ${i}`,
|
|
139
|
+
}));
|
|
140
|
+
const limit = 50;
|
|
141
|
+
const limited = emails.length > limit ? emails.slice(0, limit) : emails;
|
|
142
|
+
expect(limited).toHaveLength(10);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('Safety Warning Logic', () => {
|
|
147
|
+
function getWarnings(senderPattern, matchPattern, matchCount) {
|
|
148
|
+
const warnings = [];
|
|
149
|
+
|
|
150
|
+
if (senderPattern && senderPattern.length < 3) {
|
|
151
|
+
warnings.push(`Short sender pattern "${senderPattern}" may match broadly`);
|
|
152
|
+
}
|
|
153
|
+
if (matchPattern && matchPattern.length < 3) {
|
|
154
|
+
warnings.push(`Short subject pattern "${matchPattern}" may match broadly`);
|
|
155
|
+
}
|
|
156
|
+
if (matchCount > 100) {
|
|
157
|
+
warnings.push(`${matchCount} emails match - large batch deletion`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return warnings;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
it('should warn on short sender pattern', () => {
|
|
164
|
+
const warnings = getWarnings('ab', null, 5);
|
|
165
|
+
expect(warnings).toHaveLength(1);
|
|
166
|
+
expect(warnings[0]).toContain('Short sender pattern');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should warn on short subject pattern', () => {
|
|
170
|
+
const warnings = getWarnings(null, 're', 5);
|
|
171
|
+
expect(warnings).toHaveLength(1);
|
|
172
|
+
expect(warnings[0]).toContain('Short subject pattern');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should warn on large batch', () => {
|
|
176
|
+
const warnings = getWarnings('linkedin', null, 150);
|
|
177
|
+
expect(warnings).toHaveLength(1);
|
|
178
|
+
expect(warnings[0]).toContain('150 emails match');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should accumulate multiple warnings', () => {
|
|
182
|
+
const warnings = getWarnings('a', 'b', 200);
|
|
183
|
+
expect(warnings).toHaveLength(3);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should not warn for valid patterns and small batches', () => {
|
|
187
|
+
const warnings = getWarnings('linkedin', 'newsletter', 10);
|
|
188
|
+
expect(warnings).toHaveLength(0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should allow exactly 3 char patterns without warning', () => {
|
|
192
|
+
const warnings = getWarnings('abc', 'def', 50);
|
|
193
|
+
expect(warnings).toHaveLength(0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should allow exactly 100 matches without warning', () => {
|
|
197
|
+
const warnings = getWarnings('linkedin', null, 100);
|
|
198
|
+
expect(warnings).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
});
|