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.
- package/.github/workflows/publish.yml +27 -0
- package/CLAUDE.md +73 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/__mocks__/@google-cloud/local-auth.js +11 -0
- package/__mocks__/googleapis.js +42 -0
- package/eslint.config.js +75 -0
- package/images/banner.png +0 -0
- package/package.json +12 -2
- package/src/cli.js +416 -7
- package/src/deletion-log.js +21 -4
- package/src/gmail-auth.js +88 -10
- package/src/gmail-monitor.js +29 -4
- package/src/state.js +5 -4
- package/src/types.js +45 -0
- package/src/utils.js +30 -0
- package/tests/analyze.test.js +165 -0
- package/tests/deletion-log.test.js +171 -0
- package/tests/gmail-auth.test.js +77 -0
- package/tests/gmail-monitor.test.js +135 -0
- package/tests/notifier.test.js +27 -0
- package/tests/setup.js +16 -0
- package/tests/setup.test.js +214 -0
- package/tests/state-multiacccount.test.js +91 -0
- package/tests/state.test.js +66 -0
- package/vitest.config.js +13 -0
- package/com.danielparedes.inboxd.plist +0 -40
- package/credentials.json +0 -1
package/src/gmail-monitor.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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 (
|
|
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
|
|
3
|
+
const { TOKEN_DIR } = require('./gmail-auth');
|
|
4
|
+
const { atomicWriteJsonSync } = require('./utils');
|
|
4
5
|
|
|
5
|
-
const STATE_DIR =
|
|
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 (
|
|
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
|
-
|
|
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
|
+
});
|