inboxd 1.0.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.
@@ -0,0 +1,40 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>com.danielparedes.inboxd</string>
7
+
8
+ <key>ProgramArguments</key>
9
+ <array>
10
+ <string>/usr/local/bin/node</string>
11
+ <string>/Users/danielparedes/Documents/Github/inboxd/src/cli.js</string>
12
+ <string>check</string>
13
+ <string>--quiet</string>
14
+ </array>
15
+
16
+ <key>WorkingDirectory</key>
17
+ <string>/Users/danielparedes/Documents/Github/inboxd</string>
18
+
19
+ <!-- Run every 5 minutes (300 seconds) -->
20
+ <key>StartInterval</key>
21
+ <integer>300</integer>
22
+
23
+ <!-- Run immediately when loaded -->
24
+ <key>RunAtLoad</key>
25
+ <true/>
26
+
27
+ <!-- Logging -->
28
+ <key>StandardOutPath</key>
29
+ <string>/tmp/inboxd.log</string>
30
+ <key>StandardErrorPath</key>
31
+ <string>/tmp/inboxd.error.log</string>
32
+
33
+ <!-- Environment variables -->
34
+ <key>EnvironmentVariables</key>
35
+ <dict>
36
+ <key>PATH</key>
37
+ <string>/usr/local/bin:/usr/bin:/bin</string>
38
+ </dict>
39
+ </dict>
40
+ </plist>
@@ -0,0 +1 @@
1
+ {"installed":{"client_id":"670632438932-arhht2o3bp94r0ug45o2accmu084eqfu.apps.googleusercontent.com","project_id":"daily-assistant-483211","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost"]}}
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "inboxd",
3
+ "version": "1.0.0",
4
+ "description": "CLI assistant for Gmail monitoring with multi-account support and AI-ready JSON output",
5
+ "main": "src/cli.js",
6
+ "bin": {
7
+ "inbox": "src/cli.js"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/dparedesi/inboxd.git"
12
+ },
13
+ "scripts": {
14
+ "test": "vitest run",
15
+ "test:watch": "vitest",
16
+ "inbox": "node src/cli.js"
17
+ },
18
+ "keywords": [
19
+ "gmail",
20
+ "cli",
21
+ "email",
22
+ "monitor",
23
+ "assistant",
24
+ "ai",
25
+ "agent",
26
+ "inbox",
27
+ "tool"
28
+ ],
29
+ "author": "Daniel Paredes",
30
+ "license": "MIT",
31
+ "type": "commonjs",
32
+ "dependencies": {
33
+ "@google-cloud/local-auth": "^3.0.1",
34
+ "boxen": "^8.0.1",
35
+ "chalk": "^5.6.2",
36
+ "commander": "^14.0.2",
37
+ "googleapis": "^169.0.0",
38
+ "node-notifier": "^10.0.1"
39
+ },
40
+ "devDependencies": {
41
+ "vitest": "^4.0.16"
42
+ }
43
+ }
package/src/cli.js ADDED
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { program } = require('commander');
4
+ const { getUnreadEmails, getEmailCount, trashEmails, getEmailById } = require('./gmail-monitor');
5
+ const { getState, updateLastCheck, markEmailsSeen, getNewEmailIds, clearOldSeenEmails } = require('./state');
6
+ const { notifyNewEmails } = require('./notifier');
7
+ const { authorize, addAccount, getAccounts, getAccountEmail, getDefaultAccount, removeAccount, removeAllAccounts, renameTokenFile } = require('./gmail-auth');
8
+ const { logDeletions, getRecentDeletions, getLogPath } = require('./deletion-log');
9
+
10
+ async function main() {
11
+ const chalk = (await import('chalk')).default;
12
+ const boxen = (await import('boxen')).default;
13
+
14
+ program
15
+ .name('inbox')
16
+ .description('Gmail monitoring CLI for Daily Assistant')
17
+ .version('1.0.0');
18
+
19
+ program
20
+ .command('auth')
21
+ .description('Authenticate a Gmail account')
22
+ .option('-a, --account <name>', 'Account name (e.g., personal, work)')
23
+ .action(async (options) => {
24
+ try {
25
+ // If account name is provided, use it. Otherwise start with a temporary name
26
+ // that we'll swap for the email address later
27
+ const isExplicitAccount = !!options.account;
28
+ let accountName = options.account || `temp_${Date.now()}`;
29
+
30
+ console.log(chalk.cyan(`Authenticating...`));
31
+ console.log(chalk.gray('A browser window will open for you to authorize access.\n'));
32
+
33
+ await authorize(accountName);
34
+ const email = await getAccountEmail(accountName);
35
+
36
+ if (!email) {
37
+ throw new Error('Could not retrieve email address from the authenticated account.');
38
+ }
39
+
40
+ // If user didn't specify an account name, we use the email
41
+ if (!isExplicitAccount) {
42
+ // Check if this email is already registered
43
+ const accounts = getAccounts();
44
+ const existing = accounts.find(a => a.email === email);
45
+
46
+ if (existing) {
47
+ console.log(chalk.yellow(`Account already registered as "${existing.name}" (${email})`));
48
+ // Clean up the temporary token
49
+ removeAccount(accountName);
50
+ return;
51
+ }
52
+
53
+ // Rename the token file from temp to email
54
+ renameTokenFile(accountName, email);
55
+ accountName = email;
56
+ }
57
+
58
+ addAccount(accountName, email);
59
+ console.log(chalk.green(`Authentication successful!`));
60
+ console.log(chalk.white(`Account "${accountName}" linked to ${email}`));
61
+
62
+ } catch (error) {
63
+ console.error(chalk.red('Authentication failed:'), error.message);
64
+ process.exit(1);
65
+ }
66
+ });
67
+
68
+ program
69
+ .command('accounts')
70
+ .description('List all configured accounts')
71
+ .action(async () => {
72
+ const accounts = getAccounts();
73
+ if (accounts.length === 0) {
74
+ console.log(chalk.gray('No accounts configured. Run: inbox auth --account <name>'));
75
+ return;
76
+ }
77
+
78
+ console.log(chalk.bold('\nConfigured Accounts:\n'));
79
+ for (const acc of accounts) {
80
+ console.log(` ${chalk.cyan(acc.name)} - ${acc.email || 'unknown email'}`);
81
+ }
82
+ console.log('');
83
+ });
84
+
85
+ program
86
+ .command('logout')
87
+ .description('Remove an account or all accounts')
88
+ .option('-a, --account <name>', 'Account to remove (or "all" to remove all)')
89
+ .option('--all', 'Remove all accounts')
90
+ .action(async (options) => {
91
+ if (options.all || options.account === 'all') {
92
+ const accounts = getAccounts();
93
+ if (accounts.length === 0) {
94
+ console.log(chalk.gray('No accounts to remove.'));
95
+ return;
96
+ }
97
+ removeAllAccounts();
98
+ console.log(chalk.green(`Removed ${accounts.length} account(s) and cleared all tokens.`));
99
+ } else if (options.account) {
100
+ const accounts = getAccounts();
101
+ const exists = accounts.find(a => a.name === options.account);
102
+ if (!exists) {
103
+ console.log(chalk.yellow(`Account "${options.account}" not found.`));
104
+ return;
105
+ }
106
+ removeAccount(options.account);
107
+ console.log(chalk.green(`Removed account "${options.account}"`));
108
+ } else {
109
+ console.log(chalk.gray('Usage: inbox logout --account <name> or inbox logout --all'));
110
+ }
111
+ });
112
+
113
+ program
114
+ .command('check')
115
+ .description('Check for new emails and send notifications')
116
+ .option('-a, --account <name>', 'Check specific account (or "all")', 'all')
117
+ .option('-q, --quiet', 'Suppress output, only send notifications')
118
+ .action(async (options) => {
119
+ try {
120
+ const accounts = options.account === 'all'
121
+ ? getAccounts().map(a => a.name)
122
+ : [options.account];
123
+
124
+ if (accounts.length === 0) {
125
+ accounts.push('default');
126
+ }
127
+
128
+ let totalNew = 0;
129
+ const allNewEmails = [];
130
+
131
+ for (const account of accounts) {
132
+ clearOldSeenEmails(7, account);
133
+
134
+ const emails = await getUnreadEmails(account, 20);
135
+ const newEmailIds = getNewEmailIds(emails.map((e) => e.id), account);
136
+ const newEmails = emails.filter((e) => newEmailIds.includes(e.id));
137
+
138
+ if (newEmails.length > 0) {
139
+ markEmailsSeen(newEmailIds, account);
140
+ allNewEmails.push(...newEmails.map(e => ({ ...e, account })));
141
+ totalNew += newEmails.length;
142
+ }
143
+
144
+ updateLastCheck(account);
145
+
146
+ if (!options.quiet && newEmails.length > 0) {
147
+ console.log(chalk.green(`[${account}] ${newEmails.length} new email(s)`));
148
+ newEmails.forEach((e) => {
149
+ console.log(chalk.white(` - ${e.subject}`));
150
+ console.log(chalk.gray(` From: ${e.from}`));
151
+ });
152
+ }
153
+ }
154
+
155
+ if (allNewEmails.length > 0) {
156
+ notifyNewEmails(allNewEmails);
157
+ }
158
+
159
+ if (!options.quiet && totalNew === 0) {
160
+ console.log(chalk.gray('No new emails since last check.'));
161
+ }
162
+ } catch (error) {
163
+ console.error(chalk.red('Error checking emails:'), error.message);
164
+ process.exit(1);
165
+ }
166
+ });
167
+
168
+ program
169
+ .command('summary')
170
+ .description('Show summary of unread emails')
171
+ .option('-a, --account <name>', 'Show specific account (or "all")', 'all')
172
+ .option('-n, --count <number>', 'Number of emails per account', '5')
173
+ .action(async (options) => {
174
+ try {
175
+ const accounts = options.account === 'all'
176
+ ? getAccounts().map(a => a.name)
177
+ : [options.account];
178
+
179
+ if (accounts.length === 0) {
180
+ accounts.push('default');
181
+ }
182
+
183
+ const maxPerAccount = parseInt(options.count, 10);
184
+ const sections = [];
185
+
186
+ for (const account of accounts) {
187
+ const count = await getEmailCount(account);
188
+ const emails = await getUnreadEmails(account, maxPerAccount);
189
+ const state = getState(account);
190
+ const lastCheckStr = state.lastCheck
191
+ ? new Date(state.lastCheck).toLocaleString()
192
+ : 'Never';
193
+
194
+ const accountInfo = getAccounts().find(a => a.name === account);
195
+ const label = accountInfo?.email || account;
196
+
197
+ let content = `${chalk.bold.cyan(label)} - ${count} unread\n`;
198
+ content += chalk.gray(`Last check: ${lastCheckStr}\n\n`);
199
+
200
+ if (emails.length > 0) {
201
+ content += emails.map((e) => {
202
+ const from = e.from.length > 35 ? e.from.substring(0, 32) + '...' : e.from;
203
+ const subject = e.subject.length > 50 ? e.subject.substring(0, 47) + '...' : e.subject;
204
+ return `${chalk.white(from)}\n ${chalk.gray(subject)}`;
205
+ }).join('\n\n');
206
+ } else {
207
+ content += chalk.gray('No unread emails');
208
+ }
209
+
210
+ sections.push(content);
211
+ }
212
+
213
+ const output = boxen(sections.join('\n\n' + chalk.gray('─'.repeat(50)) + '\n\n'), {
214
+ padding: 1,
215
+ margin: 1,
216
+ borderStyle: 'round',
217
+ borderColor: 'cyan',
218
+ title: 'Inbox Summary',
219
+ titleAlignment: 'center',
220
+ });
221
+
222
+ console.log(output);
223
+ } catch (error) {
224
+ console.error(chalk.red('Error fetching summary:'), error.message);
225
+ process.exit(1);
226
+ }
227
+ });
228
+
229
+ program
230
+ .command('analyze')
231
+ .description('Output structured email data for AI analysis (unread only by default)')
232
+ .option('-a, --account <name>', 'Account to analyze (or "all")', 'all')
233
+ .option('-n, --count <number>', 'Number of emails to analyze per account', '20')
234
+ .option('--all', 'Include read and unread emails (default: unread only)')
235
+ .action(async (options) => {
236
+ try {
237
+ const accounts = options.account === 'all'
238
+ ? getAccounts().map(a => a.name)
239
+ : [options.account];
240
+
241
+ if (accounts.length === 0) {
242
+ accounts.push('default');
243
+ }
244
+
245
+ const maxPerAccount = parseInt(options.count, 10);
246
+ const includeRead = !!options.all;
247
+ const allEmails = [];
248
+
249
+ for (const account of accounts) {
250
+ const emails = await getUnreadEmails(account, maxPerAccount, includeRead);
251
+ allEmails.push(...emails);
252
+ }
253
+
254
+ // Output pure JSON for AI consumption
255
+ console.log(JSON.stringify(allEmails, null, 2));
256
+ } catch (error) {
257
+ console.error(JSON.stringify({ error: error.message }));
258
+ process.exit(1);
259
+ }
260
+ });
261
+
262
+ program
263
+ .command('delete')
264
+ .description('Move emails to trash')
265
+ .requiredOption('--ids <ids>', 'Comma-separated message IDs to delete')
266
+ .option('-a, --account <name>', 'Account name (required for single-account delete)')
267
+ .option('--confirm', 'Skip confirmation prompt')
268
+ .action(async (options) => {
269
+ try {
270
+ const ids = options.ids.split(',').map(id => id.trim()).filter(Boolean);
271
+
272
+ if (ids.length === 0) {
273
+ console.log(chalk.yellow('No message IDs provided.'));
274
+ return;
275
+ }
276
+
277
+ // Get account - if not specified, try to find from configured accounts
278
+ let account = options.account;
279
+ if (!account) {
280
+ const accounts = getAccounts();
281
+ if (accounts.length === 1) {
282
+ account = accounts[0].name;
283
+ } else if (accounts.length > 1) {
284
+ console.log(chalk.yellow('Multiple accounts configured. Please specify --account <name>'));
285
+ console.log(chalk.gray('Available accounts:'));
286
+ accounts.forEach(a => console.log(chalk.gray(` - ${a.name}`)));
287
+ return;
288
+ } else {
289
+ account = 'default';
290
+ }
291
+ }
292
+
293
+ // Fetch email details for logging before deletion
294
+ console.log(chalk.cyan(`Fetching ${ids.length} email(s) for deletion...`));
295
+ const emailsToDelete = [];
296
+
297
+ for (const id of ids) {
298
+ const email = await getEmailById(account, id);
299
+ if (email) {
300
+ emailsToDelete.push(email);
301
+ } else {
302
+ console.log(chalk.yellow(`Could not find email with ID: ${id}`));
303
+ }
304
+ }
305
+
306
+ if (emailsToDelete.length === 0) {
307
+ console.log(chalk.yellow('No valid emails found to delete.'));
308
+ return;
309
+ }
310
+
311
+ // Show what will be deleted
312
+ if (!options.confirm) {
313
+ console.log(chalk.bold('\nEmails to be moved to trash:\n'));
314
+ emailsToDelete.forEach(e => {
315
+ const from = e.from.length > 40 ? e.from.substring(0, 37) + '...' : e.from;
316
+ const subject = e.subject.length > 50 ? e.subject.substring(0, 47) + '...' : e.subject;
317
+ console.log(chalk.white(` ${from}`));
318
+ console.log(chalk.gray(` ${subject}\n`));
319
+ });
320
+ console.log(chalk.yellow(`\nThis will move ${emailsToDelete.length} email(s) to trash.`));
321
+ console.log(chalk.gray('Use --confirm to skip this prompt.\n'));
322
+ }
323
+
324
+ // Log deletions BEFORE actually deleting
325
+ logDeletions(emailsToDelete);
326
+ console.log(chalk.gray(`Logged to: ${getLogPath()}`));
327
+
328
+ // Perform the deletion
329
+ const results = await trashEmails(account, emailsToDelete.map(e => e.id));
330
+
331
+ const succeeded = results.filter(r => r.success).length;
332
+ const failed = results.filter(r => !r.success).length;
333
+
334
+ if (succeeded > 0) {
335
+ console.log(chalk.green(`\nMoved ${succeeded} email(s) to trash.`));
336
+ }
337
+ if (failed > 0) {
338
+ console.log(chalk.red(`Failed to delete ${failed} email(s).`));
339
+ results.filter(r => !r.success).forEach(r => {
340
+ console.log(chalk.red(` - ${r.id}: ${r.error}`));
341
+ });
342
+ }
343
+
344
+ } catch (error) {
345
+ if (error.message.includes('403') || error.code === 403) {
346
+ console.error(chalk.red('Permission denied. You may need to re-authenticate with updated scopes.'));
347
+ console.error(chalk.yellow('Run: node src/cli.js auth'));
348
+ } else {
349
+ console.error(chalk.red('Error deleting emails:'), error.message);
350
+ }
351
+ process.exit(1);
352
+ }
353
+ });
354
+
355
+ program
356
+ .command('deletion-log')
357
+ .description('View recent email deletions')
358
+ .option('-n, --days <number>', 'Show deletions from last N days', '30')
359
+ .action(async (options) => {
360
+ const days = parseInt(options.days, 10);
361
+ const deletions = getRecentDeletions(days);
362
+
363
+ if (deletions.length === 0) {
364
+ console.log(chalk.gray(`No deletions in the last ${days} days.`));
365
+ console.log(chalk.gray(`Log file: ${getLogPath()}`));
366
+ return;
367
+ }
368
+
369
+ console.log(chalk.bold(`\nDeletion Log (last ${days} days):\n`));
370
+
371
+ // Group by date
372
+ const byDate = {};
373
+ deletions.forEach(d => {
374
+ const date = new Date(d.deletedAt).toLocaleDateString();
375
+ if (!byDate[date]) byDate[date] = [];
376
+ byDate[date].push(d);
377
+ });
378
+
379
+ for (const [date, items] of Object.entries(byDate)) {
380
+ console.log(chalk.cyan(`${date} (${items.length} deleted)`));
381
+ items.forEach(d => {
382
+ const from = d.from.length > 35 ? d.from.substring(0, 32) + '...' : d.from;
383
+ const subject = d.subject.length > 45 ? d.subject.substring(0, 42) + '...' : d.subject;
384
+ console.log(chalk.white(` ${from}`));
385
+ console.log(chalk.gray(` ${subject}`));
386
+ console.log(chalk.gray(` ID: ${d.id} | Account: ${d.account}\n`));
387
+ });
388
+ }
389
+
390
+ console.log(chalk.gray(`\nLog file: ${getLogPath()}`));
391
+ });
392
+
393
+ program.parse();
394
+ }
395
+
396
+ main().catch(console.error);
@@ -0,0 +1,87 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const LOG_DIR = path.join(os.homedir(), '.config', 'inboxd');
6
+ const LOG_FILE = path.join(LOG_DIR, 'deletion-log.json');
7
+
8
+ /**
9
+ * Ensures the log directory exists
10
+ */
11
+ function ensureLogDir() {
12
+ if (!fs.existsSync(LOG_DIR)) {
13
+ fs.mkdirSync(LOG_DIR, { recursive: true });
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Reads the current deletion log
19
+ * @returns {Array} Array of deletion entries
20
+ */
21
+ function readLog() {
22
+ ensureLogDir();
23
+ if (!fs.existsSync(LOG_FILE)) {
24
+ return [];
25
+ }
26
+ try {
27
+ const content = fs.readFileSync(LOG_FILE, 'utf8');
28
+ return JSON.parse(content);
29
+ } catch (err) {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Logs deleted emails to the deletion log
36
+ * @param {Array} emails - Array of email objects with id, threadId, account, from, subject, snippet
37
+ */
38
+ function logDeletions(emails) {
39
+ ensureLogDir();
40
+ const log = readLog();
41
+ const timestamp = new Date().toISOString();
42
+
43
+ for (const email of emails) {
44
+ log.push({
45
+ deletedAt: timestamp,
46
+ account: email.account,
47
+ id: email.id,
48
+ threadId: email.threadId,
49
+ from: email.from,
50
+ subject: email.subject,
51
+ snippet: email.snippet,
52
+ });
53
+ }
54
+
55
+ fs.writeFileSync(LOG_FILE, JSON.stringify(log, null, 2));
56
+ }
57
+
58
+ /**
59
+ * Gets recent deletions from the log
60
+ * @param {number} days - Number of days to look back (default: 30)
61
+ * @returns {Array} Array of deletion entries within the time range
62
+ */
63
+ function getRecentDeletions(days = 30) {
64
+ const log = readLog();
65
+ const cutoff = new Date();
66
+ cutoff.setDate(cutoff.getDate() - days);
67
+
68
+ return log.filter((entry) => {
69
+ const deletedAt = new Date(entry.deletedAt);
70
+ return deletedAt >= cutoff;
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Gets the path to the log file (for display purposes)
76
+ * @returns {string} The log file path
77
+ */
78
+ function getLogPath() {
79
+ return LOG_FILE;
80
+ }
81
+
82
+ module.exports = {
83
+ logDeletions,
84
+ getRecentDeletions,
85
+ getLogPath,
86
+ readLog,
87
+ };
@@ -0,0 +1,203 @@
1
+ const fs = require('fs').promises;
2
+ const fsSync = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { authenticate } = require('@google-cloud/local-auth');
6
+ const { google } = require('googleapis');
7
+
8
+ const SCOPES = ['https://www.googleapis.com/auth/gmail.modify'];
9
+
10
+ const TOKEN_DIR = process.env.INBOXD_TOKEN_DIR || path.join(os.homedir(), '.config', 'inboxd');
11
+ const ACCOUNTS_FILE = path.join(TOKEN_DIR, 'accounts.json');
12
+
13
+ function getCredentialsPath() {
14
+ return process.env.GMAIL_CREDENTIALS_PATH || path.join(process.cwd(), 'credentials.json');
15
+ }
16
+
17
+ function getTokenPath(account = 'default') {
18
+ return path.join(TOKEN_DIR, `token-${account}.json`);
19
+ }
20
+
21
+ function loadAccounts() {
22
+ try {
23
+ if (fsSync.existsSync(ACCOUNTS_FILE)) {
24
+ return JSON.parse(fsSync.readFileSync(ACCOUNTS_FILE, 'utf8'));
25
+ }
26
+ } catch (err) {}
27
+ return { accounts: [], defaultAccount: null };
28
+ }
29
+
30
+ function saveAccounts(data) {
31
+ fsSync.mkdirSync(TOKEN_DIR, { recursive: true });
32
+ fsSync.writeFileSync(ACCOUNTS_FILE, JSON.stringify(data, null, 2));
33
+ }
34
+
35
+ function getAccounts() {
36
+ return loadAccounts().accounts;
37
+ }
38
+
39
+ function addAccount(name, email) {
40
+ const data = loadAccounts();
41
+ const existing = data.accounts.find(a => a.name === name);
42
+ if (existing) {
43
+ existing.email = email;
44
+ } else {
45
+ data.accounts.push({ name, email });
46
+ }
47
+ if (!data.defaultAccount) {
48
+ data.defaultAccount = name;
49
+ }
50
+ saveAccounts(data);
51
+ }
52
+
53
+ function getDefaultAccount() {
54
+ const data = loadAccounts();
55
+ return data.defaultAccount || (data.accounts[0]?.name) || 'default';
56
+ }
57
+
58
+ function removeAccount(name) {
59
+ const data = loadAccounts();
60
+ data.accounts = data.accounts.filter(a => a.name !== name);
61
+ if (data.defaultAccount === name) {
62
+ data.defaultAccount = data.accounts[0]?.name || null;
63
+ }
64
+ saveAccounts(data);
65
+
66
+ // Remove token file
67
+ const tokenPath = getTokenPath(name);
68
+ if (fsSync.existsSync(tokenPath)) {
69
+ fsSync.unlinkSync(tokenPath);
70
+ }
71
+
72
+ // Remove state file
73
+ const statePath = path.join(TOKEN_DIR, `state-${name}.json`);
74
+ if (fsSync.existsSync(statePath)) {
75
+ fsSync.unlinkSync(statePath);
76
+ }
77
+ }
78
+
79
+ function removeAllAccounts() {
80
+ const data = loadAccounts();
81
+ const accountNames = data.accounts.map(a => a.name);
82
+
83
+ for (const name of accountNames) {
84
+ const tokenPath = getTokenPath(name);
85
+ if (fsSync.existsSync(tokenPath)) {
86
+ fsSync.unlinkSync(tokenPath);
87
+ }
88
+ const statePath = path.join(TOKEN_DIR, `state-${name}.json`);
89
+ if (fsSync.existsSync(statePath)) {
90
+ fsSync.unlinkSync(statePath);
91
+ }
92
+ }
93
+
94
+ // Also clean up any legacy files
95
+ const legacyToken = path.join(TOKEN_DIR, 'token.json');
96
+ if (fsSync.existsSync(legacyToken)) {
97
+ fsSync.unlinkSync(legacyToken);
98
+ }
99
+ const legacyState = path.join(TOKEN_DIR, 'state.json');
100
+ if (fsSync.existsSync(legacyState)) {
101
+ fsSync.unlinkSync(legacyState);
102
+ }
103
+
104
+ saveAccounts({ accounts: [], defaultAccount: null });
105
+ }
106
+
107
+ function renameTokenFile(oldName, newName) {
108
+ const oldPath = getTokenPath(oldName);
109
+ const newPath = getTokenPath(newName);
110
+ if (fsSync.existsSync(oldPath)) {
111
+ fsSync.renameSync(oldPath, newPath);
112
+ }
113
+ }
114
+
115
+ async function loadSavedCredentialsIfExist(account = 'default') {
116
+ try {
117
+ const tokenPath = getTokenPath(account);
118
+ const content = await fs.readFile(tokenPath, 'utf8');
119
+ const credentials = JSON.parse(content);
120
+ return google.auth.fromJSON(credentials);
121
+ } catch (err) {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ async function saveCredentials(client, account = 'default') {
127
+ const credentialsPath = getCredentialsPath();
128
+ const content = await fs.readFile(credentialsPath, 'utf8');
129
+ const keys = JSON.parse(content);
130
+ const key = keys.installed || keys.web;
131
+ const payload = JSON.stringify({
132
+ type: 'authorized_user',
133
+ client_id: key.client_id,
134
+ client_secret: key.client_secret,
135
+ refresh_token: client.credentials.refresh_token,
136
+ });
137
+
138
+ await fs.mkdir(TOKEN_DIR, { recursive: true });
139
+ await fs.writeFile(getTokenPath(account), payload);
140
+ }
141
+
142
+ async function authorize(account = 'default') {
143
+ let client = await loadSavedCredentialsIfExist(account);
144
+ if (client) {
145
+ return client;
146
+ }
147
+
148
+ const credentialsPath = getCredentialsPath();
149
+
150
+ try {
151
+ await fs.access(credentialsPath);
152
+ } catch (err) {
153
+ throw new Error(
154
+ `credentials.json not found at ${credentialsPath}\n\n` +
155
+ `To set up Gmail API access:\n` +
156
+ `1. Go to https://console.cloud.google.com/\n` +
157
+ `2. Create a new project\n` +
158
+ `3. Enable the Gmail API\n` +
159
+ `4. Create OAuth 2.0 credentials (Desktop app)\n` +
160
+ `5. Download and save as credentials.json in the project root`
161
+ );
162
+ }
163
+
164
+ client = await authenticate({
165
+ scopes: SCOPES,
166
+ keyfilePath: credentialsPath,
167
+ });
168
+
169
+ if (client.credentials) {
170
+ await saveCredentials(client, account);
171
+ }
172
+ return client;
173
+ }
174
+
175
+ async function getGmailClient(account = 'default') {
176
+ const auth = await authorize(account);
177
+ return google.gmail({ version: 'v1', auth });
178
+ }
179
+
180
+ async function getAccountEmail(account = 'default') {
181
+ try {
182
+ const gmail = await getGmailClient(account);
183
+ const profile = await gmail.users.getProfile({ userId: 'me' });
184
+ return profile.data.emailAddress;
185
+ } catch (err) {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ module.exports = {
191
+ getGmailClient,
192
+ authorize,
193
+ getTokenPath,
194
+ getCredentialsPath,
195
+ getAccounts,
196
+ addAccount,
197
+ removeAccount,
198
+ removeAllAccounts,
199
+ getDefaultAccount,
200
+ getAccountEmail,
201
+ renameTokenFile,
202
+ TOKEN_DIR,
203
+ };
@@ -0,0 +1,165 @@
1
+ const { getGmailClient } = require('./gmail-auth');
2
+
3
+ /**
4
+ * Executes an async operation with a single retry on network errors.
5
+ * Max 2 attempts total (initial + 1 retry).
6
+ * Delays 1 second before retry.
7
+ */
8
+ async function withRetry(operation) {
9
+ try {
10
+ return await operation();
11
+ } catch (error) {
12
+ // Do not retry on auth errors
13
+ if (error.code === 401 || error.code === 403 || error.response?.status === 401 || error.response?.status === 403) {
14
+ throw error;
15
+ }
16
+
17
+ const networkErrors = ['ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN'];
18
+ const isNetworkError = networkErrors.includes(error.code);
19
+ const isServerError = error.response?.status >= 500;
20
+
21
+ if (isNetworkError || isServerError) {
22
+ // console.log('Retrying operation due to error:', error.message);
23
+ await new Promise((resolve) => setTimeout(resolve, 1000));
24
+ return await operation();
25
+ }
26
+
27
+ throw error;
28
+ }
29
+ }
30
+
31
+ async function getUnreadEmails(account = 'default', maxResults = 20, includeRead = false) {
32
+ try {
33
+ const gmail = await getGmailClient(account);
34
+
35
+ const res = await withRetry(() => gmail.users.messages.list({
36
+ userId: 'me',
37
+ q: includeRead ? '' : 'is:unread',
38
+ maxResults,
39
+ }));
40
+
41
+ const messages = res.data.messages;
42
+ if (!messages || messages.length === 0) {
43
+ return [];
44
+ }
45
+
46
+ const emailPromises = messages.map(async (msg) => {
47
+ try {
48
+ const detail = await withRetry(() => gmail.users.messages.get({
49
+ userId: 'me',
50
+ id: msg.id,
51
+ format: 'metadata',
52
+ metadataHeaders: ['From', 'Subject', 'Date'],
53
+ }));
54
+
55
+ const headers = detail.data.payload.headers;
56
+ const getHeader = (name) => {
57
+ const header = headers.find((h) => h.name === name);
58
+ return header ? header.value : '';
59
+ };
60
+
61
+ return {
62
+ id: msg.id,
63
+ threadId: detail.data.threadId,
64
+ labelIds: detail.data.labelIds || [],
65
+ account,
66
+ from: getHeader('From'),
67
+ subject: getHeader('Subject'),
68
+ snippet: detail.data.snippet,
69
+ date: getHeader('Date'),
70
+ };
71
+ } catch (err) {
72
+ return null;
73
+ }
74
+ });
75
+
76
+ const results = await Promise.all(emailPromises);
77
+ return results.filter((email) => email !== null);
78
+ } catch (error) {
79
+ console.error(`Error in getUnreadEmails for ${account}:`, error.message);
80
+ return [];
81
+ }
82
+ }
83
+
84
+ async function getEmailCount(account = 'default') {
85
+ try {
86
+ const gmail = await getGmailClient(account);
87
+ const res = await withRetry(() => gmail.users.labels.get({
88
+ userId: 'me',
89
+ id: 'INBOX',
90
+ }));
91
+ return res.data.messagesUnread || 0;
92
+ } catch (error) {
93
+ console.error(`Error in getEmailCount for ${account}:`, error.message);
94
+ return 0;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Moves emails to trash
100
+ * @param {string} account - Account name
101
+ * @param {Array<string>} messageIds - Array of message IDs to trash
102
+ * @returns {Array<{id: string, success: boolean, error?: string}>} Results for each message
103
+ */
104
+ async function trashEmails(account, messageIds) {
105
+ const gmail = await getGmailClient(account);
106
+ const results = [];
107
+
108
+ for (const id of messageIds) {
109
+ try {
110
+ await withRetry(() => gmail.users.messages.trash({
111
+ userId: 'me',
112
+ id: id,
113
+ }));
114
+ results.push({ id, success: true });
115
+ } catch (err) {
116
+ results.push({ id, success: false, error: err.message });
117
+ }
118
+ }
119
+
120
+ return results;
121
+ }
122
+
123
+ /**
124
+ * Gets email details by ID (for logging before deletion)
125
+ * @param {string} account - Account name
126
+ * @param {string} messageId - Message ID
127
+ * @returns {Object|null} Email object or null if not found
128
+ */
129
+ async function getEmailById(account, messageId) {
130
+ try {
131
+ const gmail = await getGmailClient(account);
132
+ const detail = await withRetry(() => gmail.users.messages.get({
133
+ userId: 'me',
134
+ id: messageId,
135
+ format: 'metadata',
136
+ metadataHeaders: ['From', 'Subject', 'Date'],
137
+ }));
138
+
139
+ const headers = detail.data.payload.headers;
140
+ const getHeader = (name) => {
141
+ const header = headers.find((h) => h.name === name);
142
+ return header ? header.value : '';
143
+ };
144
+
145
+ return {
146
+ id: messageId,
147
+ threadId: detail.data.threadId,
148
+ labelIds: detail.data.labelIds || [],
149
+ account,
150
+ from: getHeader('From'),
151
+ subject: getHeader('Subject'),
152
+ snippet: detail.data.snippet,
153
+ date: getHeader('Date'),
154
+ };
155
+ } catch (err) {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ module.exports = {
161
+ getUnreadEmails,
162
+ getEmailCount,
163
+ trashEmails,
164
+ getEmailById,
165
+ };
@@ -0,0 +1,48 @@
1
+ const notifier = require('node-notifier');
2
+
3
+ function notify({ title, message, subtitle }) {
4
+ notifier.notify({
5
+ title: title || 'Inboxd',
6
+ message: message,
7
+ subtitle: subtitle || 'Inboxd',
8
+ sound: true,
9
+ wait: false
10
+ });
11
+ }
12
+
13
+ function extractSenderName(from) {
14
+ if (!from) return 'Unknown';
15
+ const match = from.match(/^"?(.*?)"?\s*<.*>$/);
16
+ return match ? match[1] : from.split('@')[0];
17
+ }
18
+
19
+ function notifyNewEmails(emails) {
20
+ if (!Array.isArray(emails) || emails.length === 0) {
21
+ return;
22
+ }
23
+
24
+ const count = emails.length;
25
+ const senders = emails.map(email => extractSenderName(email.from));
26
+
27
+ // Get first 3 unique senders for the preview
28
+ const uniqueSenders = [...new Set(senders)];
29
+ const previewSenders = uniqueSenders.slice(0, 3);
30
+
31
+ let message = `From: ${previewSenders.join(', ')}`;
32
+
33
+ if (uniqueSenders.length > 3) {
34
+ message += `, and ${uniqueSenders.length - 3} others`;
35
+ }
36
+
37
+ notify({
38
+ title: `${count} New Email${count === 1 ? '' : 's'}`,
39
+ message: message,
40
+ subtitle: 'Inboxd'
41
+ });
42
+ }
43
+
44
+ module.exports = {
45
+ notify,
46
+ notifyNewEmails,
47
+ extractSenderName
48
+ };
package/src/state.js ADDED
@@ -0,0 +1,106 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ const STATE_DIR = path.join(os.homedir(), '.config', 'inboxd');
6
+
7
+ function getStatePath(account = 'default') {
8
+ return path.join(STATE_DIR, `state-${account}.json`);
9
+ }
10
+
11
+ function ensureDir() {
12
+ if (!fs.existsSync(STATE_DIR)) {
13
+ fs.mkdirSync(STATE_DIR, { recursive: true });
14
+ }
15
+ }
16
+
17
+ function loadState(account = 'default') {
18
+ try {
19
+ const statePath = getStatePath(account);
20
+ if (fs.existsSync(statePath)) {
21
+ const content = fs.readFileSync(statePath, 'utf8');
22
+ return JSON.parse(content);
23
+ }
24
+ } catch (error) {}
25
+ return {
26
+ lastCheck: null,
27
+ seenEmailIds: [],
28
+ lastNotifiedAt: null,
29
+ };
30
+ }
31
+
32
+ function saveState(state, account = 'default') {
33
+ ensureDir();
34
+ fs.writeFileSync(getStatePath(account), JSON.stringify(state, null, 2));
35
+ }
36
+
37
+ function getState(account = 'default') {
38
+ return loadState(account);
39
+ }
40
+
41
+ function updateLastCheck(account = 'default') {
42
+ const state = loadState(account);
43
+ state.lastCheck = Date.now();
44
+ saveState(state, account);
45
+ }
46
+
47
+ function markEmailsSeen(ids, account = 'default') {
48
+ if (!Array.isArray(ids) || ids.length === 0) return;
49
+
50
+ const state = loadState(account);
51
+ const seen = state.seenEmailIds || [];
52
+ const now = Date.now();
53
+
54
+ const existingIds = new Set(seen.map((item) => (typeof item === 'string' ? item : item.id)));
55
+
56
+ const newEntries = ids
57
+ .filter((id) => !existingIds.has(id))
58
+ .map((id) => ({ id, timestamp: now }));
59
+
60
+ if (newEntries.length > 0) {
61
+ state.seenEmailIds = [...seen, ...newEntries];
62
+ saveState(state, account);
63
+ }
64
+ }
65
+
66
+ function isEmailSeen(id, account = 'default') {
67
+ const state = loadState(account);
68
+ const seen = state.seenEmailIds || [];
69
+ return seen.some((item) => (typeof item === 'string' ? item === id : item.id === id));
70
+ }
71
+
72
+ function clearOldSeenEmails(olderThanDays = 7, account = 'default') {
73
+ const state = loadState(account);
74
+ const seen = state.seenEmailIds || [];
75
+ const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
76
+
77
+ const filtered = seen.filter((item) => {
78
+ if (typeof item === 'string') return true;
79
+ return item.timestamp > cutoff;
80
+ });
81
+
82
+ if (filtered.length !== seen.length) {
83
+ state.seenEmailIds = filtered;
84
+ saveState(state, account);
85
+ }
86
+ }
87
+
88
+ function getNewEmailIds(emailIds, account = 'default') {
89
+ return emailIds.filter((id) => !isEmailSeen(id, account));
90
+ }
91
+
92
+ function updateLastNotifiedAt(account = 'default') {
93
+ const state = loadState(account);
94
+ state.lastNotifiedAt = Date.now();
95
+ saveState(state, account);
96
+ }
97
+
98
+ module.exports = {
99
+ getState,
100
+ updateLastCheck,
101
+ markEmailsSeen,
102
+ isEmailSeen,
103
+ clearOldSeenEmails,
104
+ getNewEmailIds,
105
+ updateLastNotifiedAt,
106
+ };