inboxd 1.0.0 → 1.0.3

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.
@@ -19,9 +19,8 @@ async function withRetry(operation) {
19
19
  const isServerError = error.response?.status >= 500;
20
20
 
21
21
  if (isNetworkError || isServerError) {
22
- // console.log('Retrying operation due to error:', error.message);
23
22
  await new Promise((resolve) => setTimeout(resolve, 1000));
24
- return await operation();
23
+ return operation();
25
24
  }
26
25
 
27
26
  throw error;
@@ -68,7 +67,7 @@ async function getUnreadEmails(account = 'default', maxResults = 20, includeRead
68
67
  snippet: detail.data.snippet,
69
68
  date: getHeader('Date'),
70
69
  };
71
- } catch (err) {
70
+ } catch (_err) {
72
71
  return null;
73
72
  }
74
73
  });
@@ -152,14 +151,40 @@ async function getEmailById(account, messageId) {
152
151
  snippet: detail.data.snippet,
153
152
  date: getHeader('Date'),
154
153
  };
155
- } catch (err) {
154
+ } catch (_err) {
156
155
  return null;
157
156
  }
158
157
  }
159
158
 
159
+ /**
160
+ * Restores emails from trash (untrash)
161
+ * @param {string} account - Account name
162
+ * @param {Array<string>} messageIds - Array of message IDs to restore
163
+ * @returns {Array<{id: string, success: boolean, error?: string}>} Results for each message
164
+ */
165
+ async function untrashEmails(account, messageIds) {
166
+ const gmail = await getGmailClient(account);
167
+ const results = [];
168
+
169
+ for (const id of messageIds) {
170
+ try {
171
+ await withRetry(() => gmail.users.messages.untrash({
172
+ userId: 'me',
173
+ id: id,
174
+ }));
175
+ results.push({ id, success: true });
176
+ } catch (err) {
177
+ results.push({ id, success: false, error: err.message });
178
+ }
179
+ }
180
+
181
+ return results;
182
+ }
183
+
160
184
  module.exports = {
161
185
  getUnreadEmails,
162
186
  getEmailCount,
163
187
  trashEmails,
164
188
  getEmailById,
189
+ untrashEmails,
165
190
  };
package/src/state.js CHANGED
@@ -1,8 +1,9 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const os = require('os');
3
+ const { TOKEN_DIR } = require('./gmail-auth');
4
+ const { atomicWriteJsonSync } = require('./utils');
4
5
 
5
- const STATE_DIR = path.join(os.homedir(), '.config', 'inboxd');
6
+ const STATE_DIR = TOKEN_DIR;
6
7
 
7
8
  function getStatePath(account = 'default') {
8
9
  return path.join(STATE_DIR, `state-${account}.json`);
@@ -21,7 +22,7 @@ function loadState(account = 'default') {
21
22
  const content = fs.readFileSync(statePath, 'utf8');
22
23
  return JSON.parse(content);
23
24
  }
24
- } catch (error) {}
25
+ } catch (_error) {}
25
26
  return {
26
27
  lastCheck: null,
27
28
  seenEmailIds: [],
@@ -31,7 +32,7 @@ function loadState(account = 'default') {
31
32
 
32
33
  function saveState(state, account = 'default') {
33
34
  ensureDir();
34
- fs.writeFileSync(getStatePath(account), JSON.stringify(state, null, 2));
35
+ atomicWriteJsonSync(getStatePath(account), state);
35
36
  }
36
37
 
37
38
  function getState(account = 'default') {
package/src/types.js ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Type definitions for inboxd data structures.
3
+ * This file contains JSDoc @typedef definitions for data shapes used in the project.
4
+ * It helps with type hinting and documentation.
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object} Account
9
+ * @property {string} name - Unique account identifier (e.g., "work", "personal")
10
+ * @property {string} email - Gmail address for this account
11
+ */
12
+
13
+ /**
14
+ * @typedef {Object} AccountState
15
+ * @property {string[]} seenEmailIds - Array of email IDs already processed
16
+ * @property {string|null} lastCheck - ISO timestamp of last check
17
+ * @property {string|null} lastNotifiedAt - ISO timestamp of last notification
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} DeletionEntry
22
+ * @property {string} id - Email message ID
23
+ * @property {string} threadId - Thread ID
24
+ * @property {string} subject - Email subject
25
+ * @property {string} from - Sender
26
+ * @property {string} account - Account name that owns this email
27
+ * @property {string} deletedAt - ISO timestamp
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} Email
32
+ * @property {string} id - Message ID
33
+ * @property {string} threadId - Thread ID
34
+ * @property {string} subject - Email subject
35
+ * @property {string} from - Sender
36
+ * @property {string} snippet - Preview text
37
+ * @property {string} date - Date string
38
+ */
39
+
40
+ /**
41
+ * @typedef {Object} Credentials
42
+ * @property {{client_id: string, client_secret: string, redirect_uris: string[]}} installed - OAuth client config
43
+ */
44
+
45
+ module.exports = {};
package/src/utils.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Utility functions for inboxd
3
+ */
4
+ const fs = require('fs');
5
+
6
+ /**
7
+ * Writes data to a file atomically (write to temp, then rename)
8
+ * This prevents data corruption if the process crashes during write.
9
+ * @param {string} filePath - Target file path
10
+ * @param {string} data - Data to write
11
+ */
12
+ function atomicWriteSync(filePath, data) {
13
+ const tmpPath = `${filePath}.tmp`;
14
+ fs.writeFileSync(tmpPath, data);
15
+ fs.renameSync(tmpPath, filePath);
16
+ }
17
+
18
+ /**
19
+ * Writes JSON data to a file atomically with pretty formatting
20
+ * @param {string} filePath - Target file path
21
+ * @param {*} data - Data to serialize as JSON
22
+ */
23
+ function atomicWriteJsonSync(filePath, data) {
24
+ atomicWriteSync(filePath, JSON.stringify(data, null, 2));
25
+ }
26
+
27
+ module.exports = {
28
+ atomicWriteSync,
29
+ atomicWriteJsonSync,
30
+ };
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ describe('Analyze Command', () => {
4
+ describe('Email data structure', () => {
5
+ it('should include labelIds and threadId in email objects', () => {
6
+ const email = {
7
+ id: 'msg123',
8
+ threadId: 'thread456',
9
+ labelIds: ['UNREAD', 'CATEGORY_PROMOTIONS'],
10
+ account: 'personal',
11
+ from: 'promo@store.com',
12
+ subject: 'Sale ends today!',
13
+ snippet: 'Don\'t miss out on 50% off...',
14
+ date: '2026-01-03',
15
+ };
16
+
17
+ expect(email.threadId).toBe('thread456');
18
+ expect(email.labelIds).toContain('CATEGORY_PROMOTIONS');
19
+ expect(email.labelIds).toContain('UNREAD');
20
+ });
21
+
22
+ it('should handle emails without labelIds gracefully', () => {
23
+ const email = {
24
+ id: 'msg123',
25
+ threadId: 'thread456',
26
+ labelIds: [],
27
+ account: 'work',
28
+ from: 'boss@company.com',
29
+ subject: 'Meeting tomorrow',
30
+ snippet: 'Can we sync up...',
31
+ date: '2026-01-03',
32
+ };
33
+
34
+ expect(email.labelIds).toEqual([]);
35
+ expect(Array.isArray(email.labelIds)).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe('JSON output format', () => {
40
+ it('should produce valid JSON array', () => {
41
+ const emails = [
42
+ {
43
+ id: 'msg1',
44
+ threadId: 'thread1',
45
+ labelIds: ['UNREAD', 'INBOX'],
46
+ account: 'personal',
47
+ from: 'friend@example.com',
48
+ subject: 'Hello!',
49
+ snippet: 'How are you doing?',
50
+ date: '2026-01-03',
51
+ },
52
+ {
53
+ id: 'msg2',
54
+ threadId: 'thread2',
55
+ labelIds: ['UNREAD', 'CATEGORY_UPDATES'],
56
+ account: 'work',
57
+ from: 'github@notifications.com',
58
+ subject: 'PR merged',
59
+ snippet: 'Your pull request was merged...',
60
+ date: '2026-01-03',
61
+ },
62
+ ];
63
+
64
+ const jsonOutput = JSON.stringify(emails, null, 2);
65
+ const parsed = JSON.parse(jsonOutput);
66
+
67
+ expect(Array.isArray(parsed)).toBe(true);
68
+ expect(parsed).toHaveLength(2);
69
+ expect(parsed[0].id).toBe('msg1');
70
+ expect(parsed[1].labelIds).toContain('CATEGORY_UPDATES');
71
+ });
72
+
73
+ it('should include all required fields for AI analysis', () => {
74
+ const email = {
75
+ id: 'msg123',
76
+ threadId: 'thread456',
77
+ labelIds: ['UNREAD', 'INBOX'],
78
+ account: 'dparedesi@uni.pe',
79
+ from: 'LinkedIn <jobs@linkedin.com>',
80
+ subject: 'New job matches for you',
81
+ snippet: 'Director of Engineering at...',
82
+ date: 'Fri, 03 Jan 2026 10:00:00 +0000',
83
+ };
84
+
85
+ // All fields needed for categorization
86
+ expect(email).toHaveProperty('from');
87
+ expect(email).toHaveProperty('subject');
88
+ expect(email).toHaveProperty('snippet');
89
+ expect(email).toHaveProperty('labelIds');
90
+ expect(email).toHaveProperty('account');
91
+ });
92
+ });
93
+
94
+ describe('Count limiting', () => {
95
+ it('should respect max count parameter', () => {
96
+ const allEmails = Array.from({ length: 50 }, (_, i) => ({
97
+ id: `msg${i}`,
98
+ threadId: `thread${i}`,
99
+ labelIds: ['UNREAD'],
100
+ account: 'test',
101
+ from: `sender${i}@example.com`,
102
+ subject: `Email ${i}`,
103
+ snippet: `Content ${i}`,
104
+ date: '2026-01-03',
105
+ }));
106
+
107
+ const maxCount = 20;
108
+ const limitedEmails = allEmails.slice(0, maxCount);
109
+
110
+ expect(limitedEmails).toHaveLength(20);
111
+ expect(limitedEmails[0].id).toBe('msg0');
112
+ expect(limitedEmails[19].id).toBe('msg19');
113
+ });
114
+ });
115
+
116
+ describe('Account filtering', () => {
117
+ it('should filter emails by account', () => {
118
+ const allEmails = [
119
+ { id: '1', account: 'personal', subject: 'Personal' },
120
+ { id: '2', account: 'work', subject: 'Work' },
121
+ { id: '3', account: 'personal', subject: 'Personal 2' },
122
+ { id: '4', account: 'other', subject: 'Other' },
123
+ ];
124
+
125
+ const workEmails = allEmails.filter(e => e.account === 'work');
126
+ const personalEmails = allEmails.filter(e => e.account === 'personal');
127
+
128
+ expect(workEmails).toHaveLength(1);
129
+ expect(personalEmails).toHaveLength(2);
130
+ });
131
+ });
132
+
133
+ describe('Gmail label categorization hints', () => {
134
+ it('should identify promotional emails by labelIds', () => {
135
+ const email = {
136
+ labelIds: ['UNREAD', 'CATEGORY_PROMOTIONS'],
137
+ };
138
+
139
+ const isPromo = email.labelIds.includes('CATEGORY_PROMOTIONS');
140
+ expect(isPromo).toBe(true);
141
+ });
142
+
143
+ it('should identify update emails by labelIds', () => {
144
+ const email = {
145
+ labelIds: ['UNREAD', 'CATEGORY_UPDATES'],
146
+ };
147
+
148
+ const isUpdate = email.labelIds.includes('CATEGORY_UPDATES');
149
+ expect(isUpdate).toBe(true);
150
+ });
151
+
152
+ it('should identify primary inbox emails', () => {
153
+ const email = {
154
+ labelIds: ['UNREAD', 'INBOX'],
155
+ };
156
+
157
+ const isPrimary = email.labelIds.includes('INBOX') &&
158
+ !email.labelIds.includes('CATEGORY_PROMOTIONS') &&
159
+ !email.labelIds.includes('CATEGORY_UPDATES') &&
160
+ !email.labelIds.includes('CATEGORY_SOCIAL');
161
+
162
+ expect(isPrimary).toBe(true);
163
+ });
164
+ });
165
+ });
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import path from 'path';
3
+ import os from 'os';
4
+
5
+ // Test the logic patterns used by deletion-log without importing the real module
6
+ // This avoids issues with fs mocking in CommonJS modules
7
+
8
+ describe('Deletion Log', () => {
9
+ const LOG_DIR = path.join(os.homedir(), '.config', 'inboxd');
10
+ const LOG_FILE = path.join(LOG_DIR, 'deletion-log.json');
11
+
12
+ describe('Log entry structure', () => {
13
+ it('should have required fields for recovery', () => {
14
+ const logEntry = {
15
+ deletedAt: '2026-01-03T15:45:00.000Z',
16
+ account: 'dparedesi@uni.pe',
17
+ id: '19b84376ff5f5ed2',
18
+ threadId: '19b84376ff5f5ed2',
19
+ from: 'L&G Pensions <clientservices@pensions.landg.com>',
20
+ subject: 'Daniel, choose or review your beneficiary',
21
+ snippet: 'Take charge of tomorrow...',
22
+ };
23
+
24
+ expect(logEntry).toHaveProperty('deletedAt');
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', () => {
40
+ it('should filter entries by 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
+ { deletedAt: now.toISOString(), id: 'recent' },
49
+ { deletedAt: tenDaysAgo.toISOString(), id: 'ten-days' },
50
+ { deletedAt: 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.deletedAt) >= 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('deletion-log.json');
73
+ });
74
+ });
75
+
76
+ describe('Batch logging', () => {
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
+ deletedAt: 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
+ });
94
+ });
95
+
96
+ describe('Empty log handling', () => {
97
+ it('should return empty array for empty log', () => {
98
+ const emptyLog = [];
99
+ expect(emptyLog).toHaveLength(0);
100
+ expect(Array.isArray(emptyLog)).toBe(true);
101
+ });
102
+
103
+ it('should handle missing log file gracefully', () => {
104
+ // Simulate the readLog behavior when file doesn't exist
105
+ const readLogSafe = (fileExists, fileContent) => {
106
+ if (!fileExists) return [];
107
+ try {
108
+ return JSON.parse(fileContent);
109
+ } catch {
110
+ return [];
111
+ }
112
+ };
113
+
114
+ expect(readLogSafe(false, '')).toEqual([]);
115
+ expect(readLogSafe(true, 'invalid json')).toEqual([]);
116
+ expect(readLogSafe(true, '[]')).toEqual([]);
117
+ });
118
+ });
119
+
120
+ describe('Log removal logic', () => {
121
+ it('should remove entries by id', () => {
122
+ const log = [
123
+ { id: '1', account: 'test' },
124
+ { id: '2', account: 'test' },
125
+ { id: '3', account: 'test' },
126
+ ];
127
+
128
+ const idsToRemove = ['2'];
129
+ const newLog = log.filter(entry => !idsToRemove.includes(entry.id));
130
+
131
+ expect(newLog).toHaveLength(2);
132
+ expect(newLog.find(e => e.id === '2')).toBeUndefined();
133
+ expect(newLog.find(e => e.id === '1')).toBeDefined();
134
+ expect(newLog.find(e => e.id === '3')).toBeDefined();
135
+ });
136
+
137
+ it('should detect when entries were removed', () => {
138
+ const log = [{ id: '1' }, { id: '2' }];
139
+ const idsToRemove = ['2'];
140
+ const newLog = log.filter(entry => !idsToRemove.includes(entry.id));
141
+
142
+ // This is the condition used in removeLogEntries to decide whether to write
143
+ const entriesWereRemoved = log.length !== newLog.length;
144
+ expect(entriesWereRemoved).toBe(true);
145
+ });
146
+
147
+ it('should not detect removal for non-existent ids', () => {
148
+ const log = [{ id: '1' }];
149
+ const idsToRemove = ['999'];
150
+ const newLog = log.filter(entry => !idsToRemove.includes(entry.id));
151
+
152
+ const entriesWereRemoved = log.length !== newLog.length;
153
+ expect(entriesWereRemoved).toBe(false);
154
+ });
155
+
156
+ it('should handle removing multiple entries', () => {
157
+ const log = [
158
+ { id: '1' },
159
+ { id: '2' },
160
+ { id: '3' },
161
+ { id: '4' },
162
+ ];
163
+
164
+ const idsToRemove = ['1', '3'];
165
+ const newLog = log.filter(entry => !idsToRemove.includes(entry.id));
166
+
167
+ expect(newLog).toHaveLength(2);
168
+ expect(newLog.map(e => e.id)).toEqual(['2', '4']);
169
+ });
170
+ });
171
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ // Use a test-specific directory to avoid touching real config
7
+ const TEST_DIR = path.join(os.tmpdir(), 'inboxd-test-' + Date.now());
8
+
9
+ describe('Gmail Auth Module', () => {
10
+ beforeEach(() => {
11
+ fs.mkdirSync(TEST_DIR, { recursive: true });
12
+ });
13
+
14
+ afterEach(() => {
15
+ fs.rmSync(TEST_DIR, { recursive: true, force: true });
16
+ });
17
+
18
+ describe('Account Management', () => {
19
+ it('should generate correct token paths for different accounts', async () => {
20
+ // We test the path generation logic
21
+ const defaultPath = `token-default.json`;
22
+ const workPath = `token-work.json`;
23
+ const personalPath = `token-personal.json`;
24
+
25
+ expect(defaultPath).toBe('token-default.json');
26
+ expect(workPath).toBe('token-work.json');
27
+ expect(personalPath).toBe('token-personal.json');
28
+ });
29
+
30
+ it('should handle account names with special characters', () => {
31
+ const safeName = (name) => name.replace(/[^a-zA-Z0-9-_]/g, '_');
32
+
33
+ expect(safeName('my-work')).toBe('my-work');
34
+ expect(safeName('personal_email')).toBe('personal_email');
35
+ expect(safeName('test account')).toBe('test_account');
36
+ });
37
+ });
38
+
39
+ describe('Credentials Path', () => {
40
+ it('should use environment variable if set', () => {
41
+ const envPath = '/custom/path/credentials.json';
42
+ const getPath = (envVar) => envVar || 'credentials.json';
43
+
44
+ expect(getPath(envPath)).toBe(envPath);
45
+ expect(getPath(undefined)).toBe('credentials.json');
46
+ });
47
+ });
48
+ });
49
+
50
+ describe('Accounts Data Structure', () => {
51
+ it('should have correct structure for accounts list', () => {
52
+ const accountsData = {
53
+ accounts: [
54
+ { name: 'default', email: 'user@gmail.com' },
55
+ { name: 'work', email: 'user@company.com' },
56
+ ],
57
+ defaultAccount: 'default',
58
+ };
59
+
60
+ expect(accountsData.accounts).toHaveLength(2);
61
+ expect(accountsData.accounts[0]).toHaveProperty('name');
62
+ expect(accountsData.accounts[0]).toHaveProperty('email');
63
+ expect(accountsData.defaultAccount).toBe('default');
64
+ });
65
+
66
+ it('should find account by name', () => {
67
+ const accounts = [
68
+ { name: 'default', email: 'user@gmail.com' },
69
+ { name: 'work', email: 'user@company.com' },
70
+ ];
71
+
72
+ const findAccount = (name) => accounts.find(a => a.name === name);
73
+
74
+ expect(findAccount('work')).toEqual({ name: 'work', email: 'user@company.com' });
75
+ expect(findAccount('nonexistent')).toBeUndefined();
76
+ });
77
+ });