inboxd 1.0.0 ā 1.0.2
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 +159 -0
- package/__mocks__/@google-cloud/local-auth.js +11 -0
- package/__mocks__/googleapis.js +42 -0
- package/eslint.config.js +75 -0
- package/package.json +12 -2
- package/src/cli.js +414 -6
- 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/cli.js
CHANGED
|
@@ -1,11 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
const { program } = require('commander');
|
|
4
|
-
const { getUnreadEmails, getEmailCount, trashEmails, getEmailById } = require('./gmail-monitor');
|
|
4
|
+
const { getUnreadEmails, getEmailCount, trashEmails, getEmailById, untrashEmails } = require('./gmail-monitor');
|
|
5
5
|
const { getState, updateLastCheck, markEmailsSeen, getNewEmailIds, clearOldSeenEmails } = require('./state');
|
|
6
6
|
const { notifyNewEmails } = require('./notifier');
|
|
7
|
-
const { authorize, addAccount, getAccounts, getAccountEmail,
|
|
8
|
-
const { logDeletions, getRecentDeletions, getLogPath } = require('./deletion-log');
|
|
7
|
+
const { authorize, addAccount, getAccounts, getAccountEmail, removeAccount, removeAllAccounts, renameTokenFile, validateCredentialsFile, hasCredentials, isConfigured, installCredentials } = require('./gmail-auth');
|
|
8
|
+
const { logDeletions, getRecentDeletions, getLogPath, readLog, removeLogEntries } = require('./deletion-log');
|
|
9
|
+
const readline = require('readline');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Prompts user for input
|
|
16
|
+
*/
|
|
17
|
+
function prompt(rl, question) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
rl.question(question, (answer) => {
|
|
20
|
+
resolve(answer.trim());
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolves a file path, expanding ~ to home directory
|
|
27
|
+
*/
|
|
28
|
+
function resolvePath(filePath) {
|
|
29
|
+
if (filePath.startsWith('~')) {
|
|
30
|
+
return path.join(os.homedir(), filePath.slice(1));
|
|
31
|
+
}
|
|
32
|
+
return path.resolve(filePath);
|
|
33
|
+
}
|
|
9
34
|
|
|
10
35
|
async function main() {
|
|
11
36
|
const chalk = (await import('chalk')).default;
|
|
@@ -13,9 +38,169 @@ async function main() {
|
|
|
13
38
|
|
|
14
39
|
program
|
|
15
40
|
.name('inbox')
|
|
16
|
-
.description('Gmail monitoring CLI
|
|
41
|
+
.description('Gmail monitoring CLI with multi-account support')
|
|
17
42
|
.version('1.0.0');
|
|
18
43
|
|
|
44
|
+
// Setup command - interactive wizard for first-time users
|
|
45
|
+
program
|
|
46
|
+
.command('setup')
|
|
47
|
+
.description('Interactive setup wizard for first-time configuration')
|
|
48
|
+
.action(async () => {
|
|
49
|
+
const open = (await import('open')).default;
|
|
50
|
+
|
|
51
|
+
const rl = readline.createInterface({
|
|
52
|
+
input: process.stdin,
|
|
53
|
+
output: process.stdout,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Handle Ctrl+C gracefully
|
|
57
|
+
rl.on('close', () => {
|
|
58
|
+
// Only show message if we're exiting unexpectedly (not from normal flow)
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
process.on('SIGINT', () => {
|
|
62
|
+
console.log(chalk.gray('\n\nSetup cancelled.\n'));
|
|
63
|
+
rl.close();
|
|
64
|
+
process.exit(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
console.log(chalk.bold.cyan('\nWelcome to inboxd! Let\'s get you set up.\n'));
|
|
69
|
+
|
|
70
|
+
// Check if already configured
|
|
71
|
+
if (hasCredentials()) {
|
|
72
|
+
const accounts = getAccounts();
|
|
73
|
+
if (accounts.length > 0) {
|
|
74
|
+
console.log(chalk.yellow('You already have accounts configured:'));
|
|
75
|
+
accounts.forEach(a => console.log(chalk.gray(` - ${a.name} (${a.email})`)));
|
|
76
|
+
console.log('');
|
|
77
|
+
const answer = await prompt(rl, chalk.white('Do you want to add another account? (y/N): '));
|
|
78
|
+
if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
|
|
79
|
+
console.log(chalk.gray('\nSetup cancelled. Run "inbox summary" to check your inbox.\n'));
|
|
80
|
+
rl.close();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Skip credentials step, go directly to auth
|
|
84
|
+
console.log(chalk.cyan('\nš Authenticate another Gmail account\n'));
|
|
85
|
+
const accountName = await prompt(rl, chalk.white(' What should we call this account? (e.g., work): '));
|
|
86
|
+
if (!accountName) {
|
|
87
|
+
console.log(chalk.yellow('Account name is required.'));
|
|
88
|
+
rl.close();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
rl.close();
|
|
92
|
+
console.log(chalk.gray('\n A browser window will open for authorization...\n'));
|
|
93
|
+
await authorize(accountName);
|
|
94
|
+
const email = await getAccountEmail(accountName);
|
|
95
|
+
if (email) {
|
|
96
|
+
addAccount(accountName, email);
|
|
97
|
+
console.log(chalk.green(`\n ā Authenticated as ${email}\n`));
|
|
98
|
+
}
|
|
99
|
+
console.log(chalk.bold.green('š Setup complete! Try: inbox summary\n'));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Step 1: Google Cloud Console
|
|
105
|
+
console.log(chalk.cyan('š Step 1: Create Google Cloud Credentials\n'));
|
|
106
|
+
console.log(chalk.white(' You\'ll need to create OAuth credentials in Google Cloud Console.'));
|
|
107
|
+
console.log(chalk.white(' This is a one-time setup that takes about 5 minutes.\n'));
|
|
108
|
+
console.log(chalk.gray(' Quick guide:'));
|
|
109
|
+
console.log(chalk.gray(' 1. Create a project (or select existing)'));
|
|
110
|
+
console.log(chalk.gray(' 2. Enable the Gmail API'));
|
|
111
|
+
console.log(chalk.gray(' 3. Configure OAuth consent screen'));
|
|
112
|
+
console.log(chalk.gray(' - Choose "External" user type'));
|
|
113
|
+
console.log(chalk.gray(' - Add your email as a test user'));
|
|
114
|
+
console.log(chalk.gray(' 4. Create credentials ā OAuth client ID ā Desktop app'));
|
|
115
|
+
console.log(chalk.gray(' 5. Download the JSON file\n'));
|
|
116
|
+
|
|
117
|
+
await prompt(rl, chalk.white(' Press Enter to open Google Cloud Console...'));
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await open('https://console.cloud.google.com/apis/credentials');
|
|
121
|
+
console.log(chalk.green('\n ā Opened Google Cloud Console in your browser\n'));
|
|
122
|
+
} catch (_err) {
|
|
123
|
+
console.log(chalk.yellow('\n Could not open browser automatically.'));
|
|
124
|
+
console.log(chalk.white(' Please visit: https://console.cloud.google.com/apis/credentials\n'));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Step 2: Get credentials file
|
|
128
|
+
console.log(chalk.cyan('š Step 2: Provide your credentials file\n'));
|
|
129
|
+
console.log(chalk.gray(' After downloading, enter the path to the file.'));
|
|
130
|
+
console.log(chalk.gray(' Tip: You can drag and drop the file into this terminal.\n'));
|
|
131
|
+
|
|
132
|
+
let credentialsPath = '';
|
|
133
|
+
let validated = false;
|
|
134
|
+
|
|
135
|
+
while (!validated) {
|
|
136
|
+
const input = await prompt(rl, chalk.white(' Path to credentials file: '));
|
|
137
|
+
|
|
138
|
+
if (!input) {
|
|
139
|
+
console.log(chalk.yellow(' Please provide a file path.\n'));
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
credentialsPath = resolvePath(input.replace(/['"]/g, '').trim()); // Remove quotes from drag-drop
|
|
144
|
+
|
|
145
|
+
const validation = validateCredentialsFile(credentialsPath);
|
|
146
|
+
if (!validation.valid) {
|
|
147
|
+
console.log(chalk.red(`\n ā ${validation.error}\n`));
|
|
148
|
+
const retry = await prompt(rl, chalk.white(' Try again? (Y/n): '));
|
|
149
|
+
if (retry.toLowerCase() === 'n' || retry.toLowerCase() === 'no') {
|
|
150
|
+
console.log(chalk.gray('\nSetup cancelled.\n'));
|
|
151
|
+
rl.close();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
validated = true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Install credentials
|
|
161
|
+
const destPath = installCredentials(credentialsPath);
|
|
162
|
+
console.log(chalk.green(`\n ā Credentials saved to ${destPath}\n`));
|
|
163
|
+
|
|
164
|
+
// Step 3: Authenticate
|
|
165
|
+
console.log(chalk.cyan('š Step 3: Authenticate your Gmail account\n'));
|
|
166
|
+
const accountName = await prompt(rl, chalk.white(' What should we call this account? (e.g., personal, work): '));
|
|
167
|
+
|
|
168
|
+
if (!accountName) {
|
|
169
|
+
console.log(chalk.yellow(' Using "default" as account name.'));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const finalAccountName = accountName || 'default';
|
|
173
|
+
rl.close();
|
|
174
|
+
|
|
175
|
+
console.log(chalk.gray('\n A browser window will open for authorization...'));
|
|
176
|
+
console.log(chalk.gray(' Sign in and allow access to your Gmail.\n'));
|
|
177
|
+
|
|
178
|
+
await authorize(finalAccountName);
|
|
179
|
+
const email = await getAccountEmail(finalAccountName);
|
|
180
|
+
|
|
181
|
+
if (email) {
|
|
182
|
+
addAccount(finalAccountName, email);
|
|
183
|
+
console.log(chalk.green(` ā Authenticated as ${email}\n`));
|
|
184
|
+
} else {
|
|
185
|
+
console.log(chalk.yellow(' Warning: Could not verify email address.\n'));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Success
|
|
189
|
+
console.log(chalk.bold.green('š You\'re all set!\n'));
|
|
190
|
+
console.log(chalk.white(' Try these commands:'));
|
|
191
|
+
console.log(chalk.cyan(' inbox summary') + chalk.gray(' - View your inbox'));
|
|
192
|
+
console.log(chalk.cyan(' inbox check') + chalk.gray(' - Check for new emails'));
|
|
193
|
+
console.log(chalk.cyan(' inbox auth -a work') + chalk.gray(' - Add another account'));
|
|
194
|
+
console.log(chalk.cyan(' inbox install-service') + chalk.gray(' - Enable background monitoring'));
|
|
195
|
+
console.log('');
|
|
196
|
+
|
|
197
|
+
} catch (error) {
|
|
198
|
+
rl.close();
|
|
199
|
+
console.error(chalk.red('\nSetup failed:'), error.message);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
19
204
|
program
|
|
20
205
|
.command('auth')
|
|
21
206
|
.description('Authenticate a Gmail account')
|
|
@@ -71,7 +256,7 @@ async function main() {
|
|
|
71
256
|
.action(async () => {
|
|
72
257
|
const accounts = getAccounts();
|
|
73
258
|
if (accounts.length === 0) {
|
|
74
|
-
console.log(chalk.gray('No accounts configured. Run: inbox
|
|
259
|
+
console.log(chalk.gray('No accounts configured. Run: inbox setup'));
|
|
75
260
|
return;
|
|
76
261
|
}
|
|
77
262
|
|
|
@@ -170,6 +355,7 @@ async function main() {
|
|
|
170
355
|
.description('Show summary of unread emails')
|
|
171
356
|
.option('-a, --account <name>', 'Show specific account (or "all")', 'all')
|
|
172
357
|
.option('-n, --count <number>', 'Number of emails per account', '5')
|
|
358
|
+
.option('--json', 'Output as JSON')
|
|
173
359
|
.action(async (options) => {
|
|
174
360
|
try {
|
|
175
361
|
const accounts = options.account === 'all'
|
|
@@ -180,6 +366,28 @@ async function main() {
|
|
|
180
366
|
accounts.push('default');
|
|
181
367
|
}
|
|
182
368
|
|
|
369
|
+
if (options.json) {
|
|
370
|
+
const result = {
|
|
371
|
+
accounts: [],
|
|
372
|
+
totalUnread: 0
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
for (const account of accounts) {
|
|
376
|
+
const count = await getEmailCount(account);
|
|
377
|
+
const accountInfo = getAccounts().find(a => a.name === account);
|
|
378
|
+
|
|
379
|
+
result.accounts.push({
|
|
380
|
+
name: account,
|
|
381
|
+
email: accountInfo?.email || account,
|
|
382
|
+
unreadCount: count
|
|
383
|
+
});
|
|
384
|
+
result.totalUnread += count;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
console.log(JSON.stringify(result, null, 2));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
183
391
|
const maxPerAccount = parseInt(options.count, 10);
|
|
184
392
|
const sections = [];
|
|
185
393
|
|
|
@@ -265,6 +473,7 @@ async function main() {
|
|
|
265
473
|
.requiredOption('--ids <ids>', 'Comma-separated message IDs to delete')
|
|
266
474
|
.option('-a, --account <name>', 'Account name (required for single-account delete)')
|
|
267
475
|
.option('--confirm', 'Skip confirmation prompt')
|
|
476
|
+
.option('--dry-run', 'Show what would be deleted without deleting')
|
|
268
477
|
.action(async (options) => {
|
|
269
478
|
try {
|
|
270
479
|
const ids = options.ids.split(',').map(id => id.trim()).filter(Boolean);
|
|
@@ -309,7 +518,7 @@ async function main() {
|
|
|
309
518
|
}
|
|
310
519
|
|
|
311
520
|
// Show what will be deleted
|
|
312
|
-
if (!options.confirm) {
|
|
521
|
+
if (!options.confirm || options.dryRun) {
|
|
313
522
|
console.log(chalk.bold('\nEmails to be moved to trash:\n'));
|
|
314
523
|
emailsToDelete.forEach(e => {
|
|
315
524
|
const from = e.from.length > 40 ? e.from.substring(0, 37) + '...' : e.from;
|
|
@@ -317,6 +526,12 @@ async function main() {
|
|
|
317
526
|
console.log(chalk.white(` ${from}`));
|
|
318
527
|
console.log(chalk.gray(` ${subject}\n`));
|
|
319
528
|
});
|
|
529
|
+
|
|
530
|
+
if (options.dryRun) {
|
|
531
|
+
console.log(chalk.yellow(`\nDry run: ${emailsToDelete.length} email(s) would be deleted.`));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
320
535
|
console.log(chalk.yellow(`\nThis will move ${emailsToDelete.length} email(s) to trash.`));
|
|
321
536
|
console.log(chalk.gray('Use --confirm to skip this prompt.\n'));
|
|
322
537
|
}
|
|
@@ -390,6 +605,199 @@ async function main() {
|
|
|
390
605
|
console.log(chalk.gray(`\nLog file: ${getLogPath()}`));
|
|
391
606
|
});
|
|
392
607
|
|
|
608
|
+
program
|
|
609
|
+
.command('restore')
|
|
610
|
+
.description('Restore deleted emails from trash')
|
|
611
|
+
.option('--ids <ids>', 'Comma-separated message IDs to restore')
|
|
612
|
+
.option('--last <number>', 'Restore the N most recent deletions', parseInt)
|
|
613
|
+
.action(async (options) => {
|
|
614
|
+
try {
|
|
615
|
+
let emailsToRestore = [];
|
|
616
|
+
|
|
617
|
+
// Scenario 1: Restore by explicit IDs
|
|
618
|
+
if (options.ids) {
|
|
619
|
+
const ids = options.ids.split(',').map(id => id.trim()).filter(Boolean);
|
|
620
|
+
const log = readLog();
|
|
621
|
+
|
|
622
|
+
for (const id of ids) {
|
|
623
|
+
// Find in log first to get the account
|
|
624
|
+
const entry = log.find(e => e.id === id);
|
|
625
|
+
if (entry) {
|
|
626
|
+
emailsToRestore.push(entry);
|
|
627
|
+
} else {
|
|
628
|
+
console.log(chalk.yellow(`Warning: ID ${id} not found in local deletion log.`));
|
|
629
|
+
console.log(chalk.gray(`Cannot determine account automatically. Please restore manually via Gmail web interface.`));
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// Scenario 2: Restore last N items
|
|
634
|
+
else if (options.last) {
|
|
635
|
+
const count = options.last;
|
|
636
|
+
const deletions = getRecentDeletions(30); // Look back 30 days
|
|
637
|
+
// Sort by deletedAt desc just in case
|
|
638
|
+
deletions.sort((a, b) => new Date(b.deletedAt) - new Date(a.deletedAt));
|
|
639
|
+
|
|
640
|
+
emailsToRestore = deletions.slice(0, count);
|
|
641
|
+
} else {
|
|
642
|
+
console.log(chalk.red('Error: Must specify either --ids or --last'));
|
|
643
|
+
console.log(chalk.gray('Examples:'));
|
|
644
|
+
console.log(chalk.gray(' inbox restore --last 1'));
|
|
645
|
+
console.log(chalk.gray(' inbox restore --ids 12345,67890'));
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (emailsToRestore.length === 0) {
|
|
650
|
+
console.log(chalk.yellow('No emails found to restore.'));
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
console.log(chalk.cyan(`Attempting to restore ${emailsToRestore.length} email(s)...`));
|
|
655
|
+
|
|
656
|
+
// Group by account to batch API calls
|
|
657
|
+
const byAccount = {};
|
|
658
|
+
for (const email of emailsToRestore) {
|
|
659
|
+
if (!byAccount[email.account]) {
|
|
660
|
+
byAccount[email.account] = [];
|
|
661
|
+
}
|
|
662
|
+
byAccount[email.account].push(email);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const successfulIds = [];
|
|
666
|
+
|
|
667
|
+
for (const [account, emails] of Object.entries(byAccount)) {
|
|
668
|
+
const ids = emails.map(e => e.id);
|
|
669
|
+
console.log(chalk.gray(`Restoring ${ids.length} email(s) for account "${account}"...`));
|
|
670
|
+
|
|
671
|
+
const results = await untrashEmails(account, ids);
|
|
672
|
+
|
|
673
|
+
const succeeded = results.filter(r => r.success);
|
|
674
|
+
const failed = results.filter(r => !r.success);
|
|
675
|
+
|
|
676
|
+
if (succeeded.length > 0) {
|
|
677
|
+
console.log(chalk.green(` ā Restored ${succeeded.length} email(s)`));
|
|
678
|
+
successfulIds.push(...succeeded.map(r => r.id));
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (failed.length > 0) {
|
|
682
|
+
console.log(chalk.red(` ā Failed to restore ${failed.length} email(s)`));
|
|
683
|
+
failed.forEach(r => {
|
|
684
|
+
console.log(chalk.gray(` - ID ${r.id}: ${r.error}`));
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Clean up log
|
|
690
|
+
if (successfulIds.length > 0) {
|
|
691
|
+
removeLogEntries(successfulIds);
|
|
692
|
+
console.log(chalk.gray(`\nRemoved ${successfulIds.length} entries from deletion log.`));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
} catch (error) {
|
|
696
|
+
console.error(chalk.red('Error restoring emails:'), error.message);
|
|
697
|
+
process.exit(1);
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
program
|
|
702
|
+
.command('install-service')
|
|
703
|
+
.description('Install background service (launchd) for macOS')
|
|
704
|
+
.option('-i, --interval <minutes>', 'Check interval in minutes', '5')
|
|
705
|
+
.action(async (options) => {
|
|
706
|
+
// Platform check
|
|
707
|
+
if (process.platform !== 'darwin') {
|
|
708
|
+
console.log(chalk.red('\nError: install-service is only supported on macOS.'));
|
|
709
|
+
console.log(chalk.gray('This command uses launchd which is macOS-specific.\n'));
|
|
710
|
+
console.log(chalk.white('For other platforms, you can set up a cron job or scheduled task:'));
|
|
711
|
+
console.log(chalk.cyan(` */5 * * * * ${process.execPath} ${path.resolve(__dirname, 'cli.js')} check --quiet`));
|
|
712
|
+
console.log('');
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const interval = parseInt(options.interval, 10);
|
|
717
|
+
const seconds = interval * 60;
|
|
718
|
+
|
|
719
|
+
// Determine paths
|
|
720
|
+
const nodePath = process.execPath;
|
|
721
|
+
const scriptPath = path.resolve(__dirname, 'cli.js');
|
|
722
|
+
const workingDir = path.resolve(__dirname, '..');
|
|
723
|
+
const plistName = 'com.danielparedes.inboxd.plist';
|
|
724
|
+
const homeDir = os.homedir();
|
|
725
|
+
const launchAgentsDir = path.join(homeDir, 'Library/LaunchAgents');
|
|
726
|
+
const plistPath = path.join(launchAgentsDir, plistName);
|
|
727
|
+
|
|
728
|
+
const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
729
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
730
|
+
<plist version="1.0">
|
|
731
|
+
<dict>
|
|
732
|
+
<key>Label</key>
|
|
733
|
+
<string>com.danielparedes.inboxd</string>
|
|
734
|
+
|
|
735
|
+
<key>ProgramArguments</key>
|
|
736
|
+
<array>
|
|
737
|
+
<string>${nodePath}</string>
|
|
738
|
+
<string>${scriptPath}</string>
|
|
739
|
+
<string>check</string>
|
|
740
|
+
<string>--quiet</string>
|
|
741
|
+
</array>
|
|
742
|
+
|
|
743
|
+
<key>WorkingDirectory</key>
|
|
744
|
+
<string>${workingDir}</string>
|
|
745
|
+
|
|
746
|
+
<!-- Run every ${interval} minutes (${seconds} seconds) -->
|
|
747
|
+
<key>StartInterval</key>
|
|
748
|
+
<integer>${seconds}</integer>
|
|
749
|
+
|
|
750
|
+
<!-- Run immediately when loaded -->
|
|
751
|
+
<key>RunAtLoad</key>
|
|
752
|
+
<true/>
|
|
753
|
+
|
|
754
|
+
<!-- Logging -->
|
|
755
|
+
<key>StandardOutPath</key>
|
|
756
|
+
<string>/tmp/inboxd.log</string>
|
|
757
|
+
<key>StandardErrorPath</key>
|
|
758
|
+
<string>/tmp/inboxd.error.log</string>
|
|
759
|
+
|
|
760
|
+
<!-- Environment variables -->
|
|
761
|
+
<key>EnvironmentVariables</key>
|
|
762
|
+
<dict>
|
|
763
|
+
<key>PATH</key>
|
|
764
|
+
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
|
|
765
|
+
</dict>
|
|
766
|
+
</dict>
|
|
767
|
+
</plist>`;
|
|
768
|
+
|
|
769
|
+
try {
|
|
770
|
+
if (!fs.existsSync(launchAgentsDir)) {
|
|
771
|
+
fs.mkdirSync(launchAgentsDir, { recursive: true });
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
fs.writeFileSync(plistPath, plistContent);
|
|
775
|
+
console.log(chalk.green(`\nService configuration generated at: ${plistPath}`));
|
|
776
|
+
console.log(chalk.white('To enable the background service, run:'));
|
|
777
|
+
console.log(chalk.cyan(` launchctl unload ${plistPath} 2>/dev/null`));
|
|
778
|
+
console.log(chalk.cyan(` launchctl load ${plistPath}`));
|
|
779
|
+
console.log('');
|
|
780
|
+
} catch (error) {
|
|
781
|
+
console.error(chalk.red('Error creating service file:'), error.message);
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// Handle unknown commands gracefully
|
|
786
|
+
program.on('command:*', (operands) => {
|
|
787
|
+
console.error(chalk.red(`\nUnknown command: ${operands[0]}`));
|
|
788
|
+
console.log(chalk.gray('Run "inbox --help" for available commands.\n'));
|
|
789
|
+
process.exit(1);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// Show helpful message for first-time users when no command is given
|
|
793
|
+
if (process.argv.length === 2) {
|
|
794
|
+
if (!isConfigured()) {
|
|
795
|
+
console.log(chalk.cyan('\nWelcome to inboxd!'));
|
|
796
|
+
console.log(chalk.white('Run ') + chalk.bold('inbox setup') + chalk.white(' to get started.\n'));
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
393
801
|
program.parse();
|
|
394
802
|
}
|
|
395
803
|
|
package/src/deletion-log.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 LOG_DIR =
|
|
6
|
+
const LOG_DIR = TOKEN_DIR;
|
|
6
7
|
const LOG_FILE = path.join(LOG_DIR, 'deletion-log.json');
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -26,7 +27,7 @@ function readLog() {
|
|
|
26
27
|
try {
|
|
27
28
|
const content = fs.readFileSync(LOG_FILE, 'utf8');
|
|
28
29
|
return JSON.parse(content);
|
|
29
|
-
} catch (
|
|
30
|
+
} catch (_err) {
|
|
30
31
|
return [];
|
|
31
32
|
}
|
|
32
33
|
}
|
|
@@ -52,7 +53,7 @@ function logDeletions(emails) {
|
|
|
52
53
|
});
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
atomicWriteJsonSync(LOG_FILE, log);
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/**
|
|
@@ -79,9 +80,25 @@ function getLogPath() {
|
|
|
79
80
|
return LOG_FILE;
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Removes entries from the deletion log (e.g., after restoration)
|
|
85
|
+
* @param {Array<string>} ids - Array of message IDs to remove
|
|
86
|
+
*/
|
|
87
|
+
function removeLogEntries(ids) {
|
|
88
|
+
ensureLogDir();
|
|
89
|
+
const log = readLog();
|
|
90
|
+
|
|
91
|
+
const newLog = log.filter(entry => !ids.includes(entry.id));
|
|
92
|
+
|
|
93
|
+
if (log.length !== newLog.length) {
|
|
94
|
+
atomicWriteJsonSync(LOG_FILE, newLog);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
82
98
|
module.exports = {
|
|
83
99
|
logDeletions,
|
|
84
100
|
getRecentDeletions,
|
|
85
101
|
getLogPath,
|
|
86
102
|
readLog,
|
|
103
|
+
removeLogEntries,
|
|
87
104
|
};
|
package/src/gmail-auth.js
CHANGED
|
@@ -11,7 +11,18 @@ const TOKEN_DIR = process.env.INBOXD_TOKEN_DIR || path.join(os.homedir(), '.conf
|
|
|
11
11
|
const ACCOUNTS_FILE = path.join(TOKEN_DIR, 'accounts.json');
|
|
12
12
|
|
|
13
13
|
function getCredentialsPath() {
|
|
14
|
-
|
|
14
|
+
if (process.env.GMAIL_CREDENTIALS_PATH) {
|
|
15
|
+
return process.env.GMAIL_CREDENTIALS_PATH;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Priority 1: Current directory (useful for development)
|
|
19
|
+
const localPath = path.join(process.cwd(), 'credentials.json');
|
|
20
|
+
if (fsSync.existsSync(localPath)) {
|
|
21
|
+
return localPath;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Priority 2: Config directory (standard for npm global install)
|
|
25
|
+
return path.join(TOKEN_DIR, 'credentials.json');
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
function getTokenPath(account = 'default') {
|
|
@@ -23,7 +34,7 @@ function loadAccounts() {
|
|
|
23
34
|
if (fsSync.existsSync(ACCOUNTS_FILE)) {
|
|
24
35
|
return JSON.parse(fsSync.readFileSync(ACCOUNTS_FILE, 'utf8'));
|
|
25
36
|
}
|
|
26
|
-
} catch (
|
|
37
|
+
} catch (_err) {}
|
|
27
38
|
return { accounts: [], defaultAccount: null };
|
|
28
39
|
}
|
|
29
40
|
|
|
@@ -112,13 +123,76 @@ function renameTokenFile(oldName, newName) {
|
|
|
112
123
|
}
|
|
113
124
|
}
|
|
114
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Validates a credentials.json file structure
|
|
128
|
+
* @param {string} filePath - Path to the credentials file
|
|
129
|
+
* @returns {{ valid: boolean, error?: string }} Validation result
|
|
130
|
+
*/
|
|
131
|
+
function validateCredentialsFile(filePath) {
|
|
132
|
+
if (!fsSync.existsSync(filePath)) {
|
|
133
|
+
return { valid: false, error: 'File not found' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const content = fsSync.readFileSync(filePath, 'utf8');
|
|
138
|
+
const json = JSON.parse(content);
|
|
139
|
+
|
|
140
|
+
if (!json.installed && !json.web) {
|
|
141
|
+
return {
|
|
142
|
+
valid: false,
|
|
143
|
+
error: 'Invalid format: missing "installed" or "web" key. Make sure you downloaded OAuth Desktop app credentials.',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const key = json.installed || json.web;
|
|
148
|
+
if (!key.client_id || !key.client_secret) {
|
|
149
|
+
return {
|
|
150
|
+
valid: false,
|
|
151
|
+
error: 'Invalid format: missing client_id or client_secret',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { valid: true };
|
|
156
|
+
} catch (err) {
|
|
157
|
+
return { valid: false, error: `Invalid JSON: ${err.message}` };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Checks if credentials are configured
|
|
163
|
+
* @returns {boolean}
|
|
164
|
+
*/
|
|
165
|
+
function hasCredentials() {
|
|
166
|
+
const credentialsPath = getCredentialsPath();
|
|
167
|
+
return fsSync.existsSync(credentialsPath);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Checks if any accounts are configured
|
|
172
|
+
* @returns {boolean}
|
|
173
|
+
*/
|
|
174
|
+
function isConfigured() {
|
|
175
|
+
return hasCredentials() && getAccounts().length > 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Copies a credentials file to the config directory
|
|
180
|
+
* @param {string} sourcePath - Source file path
|
|
181
|
+
*/
|
|
182
|
+
function installCredentials(sourcePath) {
|
|
183
|
+
fsSync.mkdirSync(TOKEN_DIR, { recursive: true });
|
|
184
|
+
const destPath = path.join(TOKEN_DIR, 'credentials.json');
|
|
185
|
+
fsSync.copyFileSync(sourcePath, destPath);
|
|
186
|
+
return destPath;
|
|
187
|
+
}
|
|
188
|
+
|
|
115
189
|
async function loadSavedCredentialsIfExist(account = 'default') {
|
|
116
190
|
try {
|
|
117
191
|
const tokenPath = getTokenPath(account);
|
|
118
192
|
const content = await fs.readFile(tokenPath, 'utf8');
|
|
119
193
|
const credentials = JSON.parse(content);
|
|
120
194
|
return google.auth.fromJSON(credentials);
|
|
121
|
-
} catch (
|
|
195
|
+
} catch (_err) {
|
|
122
196
|
return null;
|
|
123
197
|
}
|
|
124
198
|
}
|
|
@@ -149,15 +223,15 @@ async function authorize(account = 'default') {
|
|
|
149
223
|
|
|
150
224
|
try {
|
|
151
225
|
await fs.access(credentialsPath);
|
|
152
|
-
} catch (
|
|
226
|
+
} catch (_err) {
|
|
153
227
|
throw new Error(
|
|
154
228
|
`credentials.json not found at ${credentialsPath}\n\n` +
|
|
155
|
-
`
|
|
229
|
+
`Run 'inbox setup' to configure Gmail API access, or manually:\n` +
|
|
156
230
|
`1. Go to https://console.cloud.google.com/\n` +
|
|
157
|
-
`2. Create a
|
|
158
|
-
`3.
|
|
159
|
-
`4. Create OAuth
|
|
160
|
-
`5. Download and save
|
|
231
|
+
`2. Create a project and enable the Gmail API\n` +
|
|
232
|
+
`3. Configure OAuth consent screen (add yourself as test user)\n` +
|
|
233
|
+
`4. Create OAuth credentials (Desktop app)\n` +
|
|
234
|
+
`5. Download and save to ~/.config/inboxd/credentials.json`
|
|
161
235
|
);
|
|
162
236
|
}
|
|
163
237
|
|
|
@@ -182,7 +256,7 @@ async function getAccountEmail(account = 'default') {
|
|
|
182
256
|
const gmail = await getGmailClient(account);
|
|
183
257
|
const profile = await gmail.users.getProfile({ userId: 'me' });
|
|
184
258
|
return profile.data.emailAddress;
|
|
185
|
-
} catch (
|
|
259
|
+
} catch (_err) {
|
|
186
260
|
return null;
|
|
187
261
|
}
|
|
188
262
|
}
|
|
@@ -199,5 +273,9 @@ module.exports = {
|
|
|
199
273
|
getDefaultAccount,
|
|
200
274
|
getAccountEmail,
|
|
201
275
|
renameTokenFile,
|
|
276
|
+
validateCredentialsFile,
|
|
277
|
+
hasCredentials,
|
|
278
|
+
isConfigured,
|
|
279
|
+
installCredentials,
|
|
202
280
|
TOKEN_DIR,
|
|
203
281
|
};
|