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/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, getDefaultAccount, removeAccount, removeAllAccounts, renameTokenFile } = require('./gmail-auth');
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 for Daily Assistant')
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 auth --account <name>'));
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
 
@@ -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 LOG_DIR = path.join(os.homedir(), '.config', 'inboxd');
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 (err) {
30
+ } catch (_err) {
30
31
  return [];
31
32
  }
32
33
  }
@@ -52,7 +53,7 @@ function logDeletions(emails) {
52
53
  });
53
54
  }
54
55
 
55
- fs.writeFileSync(LOG_FILE, JSON.stringify(log, null, 2));
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
- return process.env.GMAIL_CREDENTIALS_PATH || path.join(process.cwd(), 'credentials.json');
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 (err) {}
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 (err) {
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 (err) {
226
+ } catch (_err) {
153
227
  throw new Error(
154
228
  `credentials.json not found at ${credentialsPath}\n\n` +
155
- `To set up Gmail API access:\n` +
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 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`
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 (err) {
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
  };