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.
@@ -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
+ });