inboxd 1.0.12 → 1.1.0
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 +156 -9
- package/CLAUDE.md +39 -8
- package/package.json +1 -1
- package/src/archive-log.js +104 -0
- package/src/cli.js +531 -17
- package/src/deletion-log.js +101 -0
- package/src/gmail-monitor.js +58 -0
- package/src/sent-log.js +35 -0
- package/tests/archive-log.test.js +196 -0
- package/tests/cleanup-suggest.test.js +239 -0
- package/tests/gmail-monitor.test.js +293 -0
- package/tests/install-service.test.js +210 -0
- package/tests/interactive-confirm.test.js +175 -0
- package/tests/json-output.test.js +189 -0
- package/tests/stats.test.js +218 -0
- package/tests/unarchive.test.js +228 -0
package/src/deletion-log.js
CHANGED
|
@@ -95,10 +95,111 @@ function removeLogEntries(ids) {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Extracts domain from email address
|
|
100
|
+
* @param {string} from - From field like "Name <email@domain.com>" or "email@domain.com"
|
|
101
|
+
* @returns {string} Domain or 'unknown'
|
|
102
|
+
*/
|
|
103
|
+
function extractDomain(from) {
|
|
104
|
+
const match = from.match(/@([a-zA-Z0-9.-]+)/);
|
|
105
|
+
return match ? match[1].toLowerCase() : 'unknown';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Gets deletion statistics for the specified period
|
|
110
|
+
* @param {number} days - Number of days to look back (default: 30)
|
|
111
|
+
* @returns {Object} Statistics object with counts and breakdowns
|
|
112
|
+
*/
|
|
113
|
+
function getStats(days = 30) {
|
|
114
|
+
const deletions = getRecentDeletions(days);
|
|
115
|
+
|
|
116
|
+
// Count by account
|
|
117
|
+
const byAccount = {};
|
|
118
|
+
deletions.forEach(d => {
|
|
119
|
+
const account = d.account || 'default';
|
|
120
|
+
byAccount[account] = (byAccount[account] || 0) + 1;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Count by sender domain
|
|
124
|
+
const bySender = {};
|
|
125
|
+
deletions.forEach(d => {
|
|
126
|
+
const domain = extractDomain(d.from || '');
|
|
127
|
+
bySender[domain] = (bySender[domain] || 0) + 1;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Sort senders by count (descending)
|
|
131
|
+
const topSenders = Object.entries(bySender)
|
|
132
|
+
.sort((a, b) => b[1] - a[1])
|
|
133
|
+
.slice(0, 10)
|
|
134
|
+
.map(([domain, count]) => ({ domain, count }));
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
total: deletions.length,
|
|
138
|
+
byAccount,
|
|
139
|
+
topSenders,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Analyzes deletion patterns to suggest cleanup actions
|
|
145
|
+
* @param {number} days - Number of days to analyze (default: 30)
|
|
146
|
+
* @returns {Object} Analysis with suggestions
|
|
147
|
+
*/
|
|
148
|
+
function analyzePatterns(days = 30) {
|
|
149
|
+
const deletions = getRecentDeletions(days);
|
|
150
|
+
|
|
151
|
+
// Group by sender domain with details
|
|
152
|
+
const senderStats = {};
|
|
153
|
+
deletions.forEach(d => {
|
|
154
|
+
const domain = extractDomain(d.from || '');
|
|
155
|
+
if (!senderStats[domain]) {
|
|
156
|
+
senderStats[domain] = { count: 0, unreadCount: 0, subjects: new Set() };
|
|
157
|
+
}
|
|
158
|
+
senderStats[domain].count++;
|
|
159
|
+
// Check if it was unread when deleted (labelIds contains UNREAD)
|
|
160
|
+
if (d.labelIds && d.labelIds.includes('UNREAD')) {
|
|
161
|
+
senderStats[domain].unreadCount++;
|
|
162
|
+
}
|
|
163
|
+
// Store unique subject patterns (first 30 chars)
|
|
164
|
+
if (d.subject) {
|
|
165
|
+
senderStats[domain].subjects.add(d.subject.substring(0, 30));
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Find frequent deleters (deleted 3+ times)
|
|
170
|
+
const frequentDeleters = Object.entries(senderStats)
|
|
171
|
+
.filter(([_, stats]) => stats.count >= 3)
|
|
172
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
173
|
+
.map(([domain, stats]) => ({
|
|
174
|
+
domain,
|
|
175
|
+
deletedCount: stats.count,
|
|
176
|
+
suggestion: 'Consider unsubscribing',
|
|
177
|
+
}));
|
|
178
|
+
|
|
179
|
+
// Find never-read senders (all deleted emails were unread)
|
|
180
|
+
const neverReadSenders = Object.entries(senderStats)
|
|
181
|
+
.filter(([_, stats]) => stats.count >= 2 && stats.unreadCount === stats.count)
|
|
182
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
183
|
+
.map(([domain, stats]) => ({
|
|
184
|
+
domain,
|
|
185
|
+
deletedCount: stats.count,
|
|
186
|
+
suggestion: 'You never read these - consider bulk cleanup',
|
|
187
|
+
}));
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
period: days,
|
|
191
|
+
totalDeleted: deletions.length,
|
|
192
|
+
frequentDeleters,
|
|
193
|
+
neverReadSenders,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
98
197
|
module.exports = {
|
|
99
198
|
logDeletions,
|
|
100
199
|
getRecentDeletions,
|
|
101
200
|
getLogPath,
|
|
102
201
|
readLog,
|
|
103
202
|
removeLogEntries,
|
|
203
|
+
getStats,
|
|
204
|
+
analyzePatterns,
|
|
104
205
|
};
|
package/src/gmail-monitor.js
CHANGED
|
@@ -209,6 +209,34 @@ async function markAsRead(account, messageIds) {
|
|
|
209
209
|
return results;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Marks emails as unread by adding the UNREAD label
|
|
214
|
+
* @param {string} account - Account name
|
|
215
|
+
* @param {Array<string>} messageIds - Array of message IDs to mark as unread
|
|
216
|
+
* @returns {Array<{id: string, success: boolean, error?: string}>} Results for each message
|
|
217
|
+
*/
|
|
218
|
+
async function markAsUnread(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
|
+
addLabelIds: ['UNREAD'],
|
|
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
|
+
|
|
212
240
|
/**
|
|
213
241
|
* Archives emails by removing the INBOX label
|
|
214
242
|
* @param {string} account - Account name
|
|
@@ -237,6 +265,34 @@ async function archiveEmails(account, messageIds) {
|
|
|
237
265
|
return results;
|
|
238
266
|
}
|
|
239
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Unarchives emails by adding the INBOX label back
|
|
270
|
+
* @param {string} account - Account name
|
|
271
|
+
* @param {Array<string>} messageIds - Array of message IDs to unarchive
|
|
272
|
+
* @returns {Array<{id: string, success: boolean, error?: string}>} Results for each message
|
|
273
|
+
*/
|
|
274
|
+
async function unarchiveEmails(account, messageIds) {
|
|
275
|
+
const gmail = await getGmailClient(account);
|
|
276
|
+
const results = [];
|
|
277
|
+
|
|
278
|
+
for (const id of messageIds) {
|
|
279
|
+
try {
|
|
280
|
+
await withRetry(() => gmail.users.messages.modify({
|
|
281
|
+
userId: 'me',
|
|
282
|
+
id: id,
|
|
283
|
+
requestBody: {
|
|
284
|
+
addLabelIds: ['INBOX'],
|
|
285
|
+
},
|
|
286
|
+
}));
|
|
287
|
+
results.push({ id, success: true });
|
|
288
|
+
} catch (err) {
|
|
289
|
+
results.push({ id, success: false, error: err.message });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return results;
|
|
294
|
+
}
|
|
295
|
+
|
|
240
296
|
/**
|
|
241
297
|
* Extracts the domain from a From header value
|
|
242
298
|
* @param {string} from - e.g., "Sender Name <sender@example.com>" or "sender@example.com"
|
|
@@ -646,7 +702,9 @@ module.exports = {
|
|
|
646
702
|
getEmailById,
|
|
647
703
|
untrashEmails,
|
|
648
704
|
markAsRead,
|
|
705
|
+
markAsUnread,
|
|
649
706
|
archiveEmails,
|
|
707
|
+
unarchiveEmails,
|
|
650
708
|
extractSenderDomain,
|
|
651
709
|
groupEmailsBySender,
|
|
652
710
|
getEmailContent,
|
package/src/sent-log.js
CHANGED
|
@@ -79,9 +79,44 @@ function getSentLogPath() {
|
|
|
79
79
|
return LOG_FILE;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Gets sent email statistics for the specified period
|
|
84
|
+
* @param {number} days - Number of days to look back (default: 30)
|
|
85
|
+
* @returns {Object} Statistics object with counts and breakdowns
|
|
86
|
+
*/
|
|
87
|
+
function getSentStats(days = 30) {
|
|
88
|
+
const sent = getRecentSent(days);
|
|
89
|
+
|
|
90
|
+
// Count replies vs new emails
|
|
91
|
+
let replies = 0;
|
|
92
|
+
let newEmails = 0;
|
|
93
|
+
sent.forEach(s => {
|
|
94
|
+
if (s.replyToId) {
|
|
95
|
+
replies++;
|
|
96
|
+
} else {
|
|
97
|
+
newEmails++;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Count by account
|
|
102
|
+
const byAccount = {};
|
|
103
|
+
sent.forEach(s => {
|
|
104
|
+
const account = s.account || 'default';
|
|
105
|
+
byAccount[account] = (byAccount[account] || 0) + 1;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
total: sent.length,
|
|
110
|
+
replies,
|
|
111
|
+
newEmails,
|
|
112
|
+
byAccount,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
82
116
|
module.exports = {
|
|
83
117
|
logSentEmail,
|
|
84
118
|
getRecentSent,
|
|
85
119
|
getSentLogPath,
|
|
86
120
|
readSentLog,
|
|
121
|
+
getSentStats,
|
|
87
122
|
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
// Test archive-log logic patterns without importing the real module
|
|
6
|
+
// This mirrors the structure of deletion-log.test.js
|
|
7
|
+
|
|
8
|
+
describe('Archive Log', () => {
|
|
9
|
+
const LOG_DIR = path.join(os.homedir(), '.config', 'inboxd');
|
|
10
|
+
const LOG_FILE = path.join(LOG_DIR, 'archive-log.json');
|
|
11
|
+
|
|
12
|
+
describe('Log entry structure', () => {
|
|
13
|
+
it('should have required fields for recovery', () => {
|
|
14
|
+
const logEntry = {
|
|
15
|
+
archivedAt: '2026-01-03T15:45:00.000Z',
|
|
16
|
+
account: 'personal@gmail.com',
|
|
17
|
+
id: '19b84376ff5f5ed2',
|
|
18
|
+
threadId: '19b84376ff5f5ed2',
|
|
19
|
+
from: 'Newsletter <news@example.com>',
|
|
20
|
+
subject: 'Weekly Digest',
|
|
21
|
+
snippet: 'This week in tech...',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
expect(logEntry).toHaveProperty('archivedAt');
|
|
25
|
+
expect(logEntry).toHaveProperty('account');
|
|
26
|
+
expect(logEntry).toHaveProperty('id');
|
|
27
|
+
expect(logEntry).toHaveProperty('threadId');
|
|
28
|
+
expect(logEntry).toHaveProperty('from');
|
|
29
|
+
expect(logEntry).toHaveProperty('subject');
|
|
30
|
+
expect(logEntry).toHaveProperty('snippet');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should include ISO timestamp', () => {
|
|
34
|
+
const timestamp = new Date().toISOString();
|
|
35
|
+
expect(timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('Log filtering by date', () => {
|
|
40
|
+
it('should filter entries within date range', () => {
|
|
41
|
+
const now = new Date();
|
|
42
|
+
const tenDaysAgo = new Date(now);
|
|
43
|
+
tenDaysAgo.setDate(tenDaysAgo.getDate() - 10);
|
|
44
|
+
const fortyDaysAgo = new Date(now);
|
|
45
|
+
fortyDaysAgo.setDate(fortyDaysAgo.getDate() - 40);
|
|
46
|
+
|
|
47
|
+
const entries = [
|
|
48
|
+
{ archivedAt: now.toISOString(), id: 'recent' },
|
|
49
|
+
{ archivedAt: tenDaysAgo.toISOString(), id: 'ten-days' },
|
|
50
|
+
{ archivedAt: fortyDaysAgo.toISOString(), id: 'forty-days' },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const cutoff = new Date();
|
|
54
|
+
cutoff.setDate(cutoff.getDate() - 30);
|
|
55
|
+
|
|
56
|
+
const recentEntries = entries.filter((e) => new Date(e.archivedAt) >= cutoff);
|
|
57
|
+
|
|
58
|
+
expect(recentEntries).toHaveLength(2);
|
|
59
|
+
expect(recentEntries.map(e => e.id)).toContain('recent');
|
|
60
|
+
expect(recentEntries.map(e => e.id)).toContain('ten-days');
|
|
61
|
+
expect(recentEntries.map(e => e.id)).not.toContain('forty-days');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('Log path', () => {
|
|
66
|
+
it('should use correct log directory path', () => {
|
|
67
|
+
expect(LOG_DIR).toContain('.config');
|
|
68
|
+
expect(LOG_DIR).toContain('inboxd');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should use correct log file name', () => {
|
|
72
|
+
expect(LOG_FILE).toContain('archive-log.json');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('Batch archiving', () => {
|
|
77
|
+
it('should handle multiple emails in one log operation', () => {
|
|
78
|
+
const emails = [
|
|
79
|
+
{ id: '1', account: 'test', from: 'a@b.com', subject: 'A', snippet: 'a', threadId: '1' },
|
|
80
|
+
{ id: '2', account: 'test', from: 'c@d.com', subject: 'B', snippet: 'b', threadId: '2' },
|
|
81
|
+
{ id: '3', account: 'test', from: 'e@f.com', subject: 'C', snippet: 'c', threadId: '3' },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const timestamp = new Date().toISOString();
|
|
85
|
+
const logEntries = emails.map(email => ({
|
|
86
|
+
archivedAt: timestamp,
|
|
87
|
+
...email,
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
expect(logEntries).toHaveLength(3);
|
|
91
|
+
expect(logEntries[0].id).toBe('1');
|
|
92
|
+
expect(logEntries[2].id).toBe('3');
|
|
93
|
+
// All should have same timestamp (batch operation)
|
|
94
|
+
expect(logEntries[0].archivedAt).toBe(logEntries[1].archivedAt);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('Empty log handling', () => {
|
|
99
|
+
it('should return empty array for empty log', () => {
|
|
100
|
+
const emptyLog = [];
|
|
101
|
+
expect(emptyLog).toHaveLength(0);
|
|
102
|
+
expect(Array.isArray(emptyLog)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should handle missing log file gracefully', () => {
|
|
106
|
+
const readLogSafe = (fileExists, fileContent) => {
|
|
107
|
+
if (!fileExists) return [];
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(fileContent);
|
|
110
|
+
} catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
expect(readLogSafe(false, '')).toEqual([]);
|
|
116
|
+
expect(readLogSafe(true, 'invalid json')).toEqual([]);
|
|
117
|
+
expect(readLogSafe(true, '[]')).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('Log removal for unarchive', () => {
|
|
122
|
+
it('should remove entries by id after unarchiving', () => {
|
|
123
|
+
const log = [
|
|
124
|
+
{ id: '1', account: 'test' },
|
|
125
|
+
{ id: '2', account: 'test' },
|
|
126
|
+
{ id: '3', account: 'test' },
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
const idsToRemove = ['2'];
|
|
130
|
+
const newLog = log.filter(entry => !idsToRemove.includes(entry.id));
|
|
131
|
+
|
|
132
|
+
expect(newLog).toHaveLength(2);
|
|
133
|
+
expect(newLog.find(e => e.id === '2')).toBeUndefined();
|
|
134
|
+
expect(newLog.find(e => e.id === '1')).toBeDefined();
|
|
135
|
+
expect(newLog.find(e => e.id === '3')).toBeDefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should handle removing multiple entries', () => {
|
|
139
|
+
const log = [
|
|
140
|
+
{ id: '1' },
|
|
141
|
+
{ id: '2' },
|
|
142
|
+
{ id: '3' },
|
|
143
|
+
{ id: '4' },
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const idsToRemove = ['1', '3'];
|
|
147
|
+
const newLog = log.filter(entry => !idsToRemove.includes(entry.id));
|
|
148
|
+
|
|
149
|
+
expect(newLog).toHaveLength(2);
|
|
150
|
+
expect(newLog.map(e => e.id)).toEqual(['2', '4']);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should detect when entries were removed', () => {
|
|
154
|
+
const log = [{ id: '1' }, { id: '2' }];
|
|
155
|
+
const idsToRemove = ['2'];
|
|
156
|
+
const newLog = log.filter(entry => !idsToRemove.includes(entry.id));
|
|
157
|
+
|
|
158
|
+
const entriesWereRemoved = log.length !== newLog.length;
|
|
159
|
+
expect(entriesWereRemoved).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('Sorting for --last N', () => {
|
|
164
|
+
it('should sort by archivedAt descending for --last retrieval', () => {
|
|
165
|
+
const now = new Date();
|
|
166
|
+
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
|
167
|
+
const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000);
|
|
168
|
+
|
|
169
|
+
const archives = [
|
|
170
|
+
{ id: '1', archivedAt: twoHoursAgo.toISOString() },
|
|
171
|
+
{ id: '2', archivedAt: now.toISOString() },
|
|
172
|
+
{ id: '3', archivedAt: oneHourAgo.toISOString() },
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
archives.sort((a, b) => new Date(b.archivedAt) - new Date(a.archivedAt));
|
|
176
|
+
|
|
177
|
+
expect(archives[0].id).toBe('2'); // most recent
|
|
178
|
+
expect(archives[1].id).toBe('3');
|
|
179
|
+
expect(archives[2].id).toBe('1'); // oldest
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should slice correct number for --last N', () => {
|
|
183
|
+
const archives = [
|
|
184
|
+
{ id: '1' },
|
|
185
|
+
{ id: '2' },
|
|
186
|
+
{ id: '3' },
|
|
187
|
+
{ id: '4' },
|
|
188
|
+
{ id: '5' },
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
const lastThree = archives.slice(0, 3);
|
|
192
|
+
expect(lastThree).toHaveLength(3);
|
|
193
|
+
expect(lastThree.map(e => e.id)).toEqual(['1', '2', '3']);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Test cleanup-suggest / analyzePatterns logic
|
|
4
|
+
// Mirrors the analyzePatterns function in deletion-log.js
|
|
5
|
+
|
|
6
|
+
describe('Cleanup Suggestions', () => {
|
|
7
|
+
// Helper to extract domain from email address
|
|
8
|
+
function extractDomain(from) {
|
|
9
|
+
const match = from.match(/@([a-zA-Z0-9.-]+)/);
|
|
10
|
+
return match ? match[1].toLowerCase() : 'unknown';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Mirrors analyzePatterns() from deletion-log.js
|
|
14
|
+
function analyzePatterns(deletions) {
|
|
15
|
+
const senderStats = {};
|
|
16
|
+
deletions.forEach(d => {
|
|
17
|
+
const domain = extractDomain(d.from || '');
|
|
18
|
+
if (!senderStats[domain]) {
|
|
19
|
+
senderStats[domain] = { count: 0, unreadCount: 0, subjects: new Set() };
|
|
20
|
+
}
|
|
21
|
+
senderStats[domain].count++;
|
|
22
|
+
if (d.labelIds && d.labelIds.includes('UNREAD')) {
|
|
23
|
+
senderStats[domain].unreadCount++;
|
|
24
|
+
}
|
|
25
|
+
if (d.subject) {
|
|
26
|
+
senderStats[domain].subjects.add(d.subject.substring(0, 30));
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const frequentDeleters = Object.entries(senderStats)
|
|
31
|
+
.filter(([_, stats]) => stats.count >= 3)
|
|
32
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
33
|
+
.map(([domain, stats]) => ({
|
|
34
|
+
domain,
|
|
35
|
+
deletedCount: stats.count,
|
|
36
|
+
suggestion: 'Consider unsubscribing',
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
const neverReadSenders = Object.entries(senderStats)
|
|
40
|
+
.filter(([_, stats]) => stats.count >= 2 && stats.unreadCount === stats.count)
|
|
41
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
42
|
+
.map(([domain, stats]) => ({
|
|
43
|
+
domain,
|
|
44
|
+
deletedCount: stats.count,
|
|
45
|
+
suggestion: 'You never read these - consider bulk cleanup',
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
period: 30,
|
|
50
|
+
totalDeleted: deletions.length,
|
|
51
|
+
frequentDeleters,
|
|
52
|
+
neverReadSenders,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('Frequent Deleters Detection', () => {
|
|
57
|
+
it('should identify senders with 3+ deletions', () => {
|
|
58
|
+
const deletions = [
|
|
59
|
+
{ id: '1', from: 'a@linkedin.com' },
|
|
60
|
+
{ id: '2', from: 'b@linkedin.com' },
|
|
61
|
+
{ id: '3', from: 'c@linkedin.com' },
|
|
62
|
+
{ id: '4', from: 'd@amazon.com' },
|
|
63
|
+
{ id: '5', from: 'e@amazon.com' },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const analysis = analyzePatterns(deletions);
|
|
67
|
+
|
|
68
|
+
expect(analysis.frequentDeleters).toHaveLength(1);
|
|
69
|
+
expect(analysis.frequentDeleters[0].domain).toBe('linkedin.com');
|
|
70
|
+
expect(analysis.frequentDeleters[0].deletedCount).toBe(3);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should not include senders with less than 3 deletions', () => {
|
|
74
|
+
const deletions = [
|
|
75
|
+
{ id: '1', from: 'a@example.com' },
|
|
76
|
+
{ id: '2', from: 'b@example.com' },
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
const analysis = analyzePatterns(deletions);
|
|
80
|
+
expect(analysis.frequentDeleters).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should sort frequent deleters by count descending', () => {
|
|
84
|
+
const deletions = [
|
|
85
|
+
{ id: '1', from: 'a@linkedin.com' },
|
|
86
|
+
{ id: '2', from: 'b@linkedin.com' },
|
|
87
|
+
{ id: '3', from: 'c@linkedin.com' },
|
|
88
|
+
{ id: '4', from: 'd@amazon.com' },
|
|
89
|
+
{ id: '5', from: 'e@amazon.com' },
|
|
90
|
+
{ id: '6', from: 'f@amazon.com' },
|
|
91
|
+
{ id: '7', from: 'g@amazon.com' },
|
|
92
|
+
{ id: '8', from: 'h@github.com' },
|
|
93
|
+
{ id: '9', from: 'i@github.com' },
|
|
94
|
+
{ id: '10', from: 'j@github.com' },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
const analysis = analyzePatterns(deletions);
|
|
98
|
+
|
|
99
|
+
expect(analysis.frequentDeleters[0].domain).toBe('amazon.com');
|
|
100
|
+
expect(analysis.frequentDeleters[0].deletedCount).toBe(4);
|
|
101
|
+
expect(analysis.frequentDeleters[1].deletedCount).toBe(3);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('Never-Read Senders Detection', () => {
|
|
106
|
+
it('should identify senders whose emails were always deleted unread', () => {
|
|
107
|
+
const deletions = [
|
|
108
|
+
{ id: '1', from: 'promo@store.com', labelIds: ['UNREAD'] },
|
|
109
|
+
{ id: '2', from: 'promo@store.com', labelIds: ['UNREAD'] },
|
|
110
|
+
{ id: '3', from: 'promo@store.com', labelIds: ['UNREAD'] },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const analysis = analyzePatterns(deletions);
|
|
114
|
+
|
|
115
|
+
expect(analysis.neverReadSenders).toHaveLength(1);
|
|
116
|
+
expect(analysis.neverReadSenders[0].domain).toBe('store.com');
|
|
117
|
+
expect(analysis.neverReadSenders[0].deletedCount).toBe(3);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should not include senders with some read emails', () => {
|
|
121
|
+
const deletions = [
|
|
122
|
+
{ id: '1', from: 'news@example.com', labelIds: ['UNREAD'] },
|
|
123
|
+
{ id: '2', from: 'news@example.com', labelIds: [] }, // was read
|
|
124
|
+
{ id: '3', from: 'news@example.com', labelIds: ['UNREAD'] },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const analysis = analyzePatterns(deletions);
|
|
128
|
+
expect(analysis.neverReadSenders).toHaveLength(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should require at least 2 deletions for never-read', () => {
|
|
132
|
+
const deletions = [
|
|
133
|
+
{ id: '1', from: 'one@example.com', labelIds: ['UNREAD'] },
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
const analysis = analyzePatterns(deletions);
|
|
137
|
+
expect(analysis.neverReadSenders).toHaveLength(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should handle missing labelIds gracefully', () => {
|
|
141
|
+
const deletions = [
|
|
142
|
+
{ id: '1', from: 'a@test.com' },
|
|
143
|
+
{ id: '2', from: 'b@test.com' },
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
// Should not throw
|
|
147
|
+
const analysis = analyzePatterns(deletions);
|
|
148
|
+
expect(analysis.neverReadSenders).toHaveLength(0);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('Analysis Output Structure', () => {
|
|
153
|
+
it('should include period and total', () => {
|
|
154
|
+
const analysis = analyzePatterns([]);
|
|
155
|
+
expect(analysis).toHaveProperty('period');
|
|
156
|
+
expect(analysis).toHaveProperty('totalDeleted');
|
|
157
|
+
expect(analysis.totalDeleted).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should provide suggestions for frequent deleters', () => {
|
|
161
|
+
const deletions = [
|
|
162
|
+
{ id: '1', from: 'a@spam.com' },
|
|
163
|
+
{ id: '2', from: 'b@spam.com' },
|
|
164
|
+
{ id: '3', from: 'c@spam.com' },
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
const analysis = analyzePatterns(deletions);
|
|
168
|
+
expect(analysis.frequentDeleters[0]).toHaveProperty('suggestion');
|
|
169
|
+
expect(analysis.frequentDeleters[0].suggestion).toContain('unsubscribing');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should provide suggestions for never-read senders', () => {
|
|
173
|
+
const deletions = [
|
|
174
|
+
{ id: '1', from: 'a@junk.com', labelIds: ['UNREAD'] },
|
|
175
|
+
{ id: '2', from: 'b@junk.com', labelIds: ['UNREAD'] },
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
const analysis = analyzePatterns(deletions);
|
|
179
|
+
expect(analysis.neverReadSenders[0]).toHaveProperty('suggestion');
|
|
180
|
+
expect(analysis.neverReadSenders[0].suggestion).toContain('bulk cleanup');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('JSON output structure', () => {
|
|
185
|
+
it('should have correct structure for JSON output', () => {
|
|
186
|
+
const jsonOutput = {
|
|
187
|
+
period: 30,
|
|
188
|
+
totalDeleted: 15,
|
|
189
|
+
frequentDeleters: [
|
|
190
|
+
{ domain: 'linkedin.com', deletedCount: 8, suggestion: 'Consider unsubscribing' }
|
|
191
|
+
],
|
|
192
|
+
neverReadSenders: [
|
|
193
|
+
{ domain: 'promo.com', deletedCount: 5, suggestion: 'You never read these...' }
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
expect(jsonOutput).toHaveProperty('period');
|
|
198
|
+
expect(jsonOutput).toHaveProperty('totalDeleted');
|
|
199
|
+
expect(jsonOutput).toHaveProperty('frequentDeleters');
|
|
200
|
+
expect(jsonOutput).toHaveProperty('neverReadSenders');
|
|
201
|
+
expect(Array.isArray(jsonOutput.frequentDeleters)).toBe(true);
|
|
202
|
+
expect(Array.isArray(jsonOutput.neverReadSenders)).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('Edge Cases', () => {
|
|
207
|
+
it('should handle empty deletions list', () => {
|
|
208
|
+
const analysis = analyzePatterns([]);
|
|
209
|
+
expect(analysis.totalDeleted).toBe(0);
|
|
210
|
+
expect(analysis.frequentDeleters).toEqual([]);
|
|
211
|
+
expect(analysis.neverReadSenders).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should handle emails with missing from field', () => {
|
|
215
|
+
const deletions = [
|
|
216
|
+
{ id: '1' },
|
|
217
|
+
{ id: '2', from: '' },
|
|
218
|
+
{ id: '3', from: null },
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
// Should not throw
|
|
222
|
+
const analysis = analyzePatterns(deletions);
|
|
223
|
+
expect(analysis.totalDeleted).toBe(3);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should group same sender with different display names', () => {
|
|
227
|
+
const deletions = [
|
|
228
|
+
{ id: '1', from: 'LinkedIn Jobs <jobs@linkedin.com>' },
|
|
229
|
+
{ id: '2', from: 'LinkedIn Connections <connect@linkedin.com>' },
|
|
230
|
+
{ id: '3', from: 'noreply@linkedin.com' },
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
const analysis = analyzePatterns(deletions);
|
|
234
|
+
expect(analysis.frequentDeleters).toHaveLength(1);
|
|
235
|
+
expect(analysis.frequentDeleters[0].domain).toBe('linkedin.com');
|
|
236
|
+
expect(analysis.frequentDeleters[0].deletedCount).toBe(3);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|