UNPKG

trash-cleaner

Version:

Finds and deletes trash email in the mailbox

729 lines (647 loc) 29.4 kB
import fs from 'fs'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; import readline from 'readline'; import { Command, Option } from 'commander'; import { FileSystemConfigStore } from './store/file-system-config-store.js'; import { SecureConfigStore } from './store/secure-config-store.js'; import { GmailClientFactory } from './client/gmail-client.js'; import { OutlookClientFactory } from './client/outlook-client.js'; import { ImapClientFactory } from './client/imap-client.js'; import { TrashCleanerFactory } from './trash-cleaner.js'; import { ActionLog } from './utils/action-log.js'; import type { ConfigStore } from './store/config-store.js'; import type { EmailClient } from './client/email-client.js'; import type { TrashCleaner } from './trash-cleaner.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')); const version: string = pkg.version; const EmailService = { IMAP: 'imap', GMAIL: 'gmail', OUTLOOK: 'outlook' } as const; const PATH_CONFIG = path.join(os.homedir(), '.config', 'trash-cleaner'); // Sample files bundled with the package const SAMPLE_DIR = path.join(__dirname, '..', 'config'); const SAMPLE_FILES: Record<string, string> = { 'keywords.yaml': 'keywords.yaml.sample', 'llm-providers.yaml': 'llm-providers.yaml.sample', 'allowlist.yaml': 'allowlist.yaml.sample', 'imap.credentials.json': 'imap.credentials.json.sample', 'gmail.credentials.json': 'gmail.credentials.json.sample', 'outlook.credentials.json': 'outlook.credentials.sample.json' }; interface ValidationIssue { file: string; level: 'ok' | 'error' | 'info'; message: string; } /** * A command line interface for the trash cleaner. */ class Cli { private _cmd: Command; constructor() { this._cmd = new Command(); this._cmd.version(version); this._cmd .addOption( new Option('-r, --reconfig', 'reconfigures the auth for a service')) .addOption( new Option('-t, --dry-run', 'perform a dry-run cleanup without deleting the emails')) .addOption( new Option('-q, --quiet', 'suppress spinner and verbose output (for cron/scripts)')) .addOption( new Option('-i, --interactive', 'preview matches and confirm before acting')) .addOption( new Option('-d, --debug', 'output extra debugging info')) .addOption( new Option('-l, --launch', 'launch the auth url in the browser')) .addOption( new Option('-c, --configDirPath <path>', 'the path to config directory') .default(PATH_CONFIG)) .addOption( new Option('-s, --service <service>', 'the email service to use') .default(EmailService.IMAP) .choices(Object.values(EmailService))) .addOption( new Option('-a, --account <name>', 'the account name for multi-account support') .default('default')) .addOption( new Option('-f, --format <format>', 'output format for the report') .default('text') .choices(['text', 'html'])) .addOption( new Option('-m, --min-age <days>', 'only process emails older than N days') .argParser(parseInt)); this._cmd.addHelpText('after', ` Commands: login save credentials securely in OS keychain logout remove credentials from OS keychain init [configDir] initialize config directory with sample files validate [configDir] validate keywords config file list-rules [configDir] list all configured keyword rules undo [configDir] undo last action using the action log`); } /** * The entry point for the command line interface. */ async run(args: string[]): Promise<boolean> { // Handle 'init' subcommand before Commander parsing const initIndex = args.indexOf('init'); if (initIndex >= 2) { const configDirPath = args[initIndex + 1] || PATH_CONFIG; return this._initConfig(configDirPath); } // Handle 'list-rules' subcommand before Commander parsing const listRulesIndex = args.indexOf('list-rules'); if (listRulesIndex >= 2) { const configDirPath = args[listRulesIndex + 1] || PATH_CONFIG; return this._listRules(configDirPath); } // Handle 'undo' subcommand before Commander parsing const undoIndex = args.indexOf('undo'); if (undoIndex >= 2) { const configDirPath = args[undoIndex + 1] || PATH_CONFIG; return this._undo(configDirPath, args); } // Handle 'validate' subcommand before Commander parsing const validateIndex = args.indexOf('validate'); if (validateIndex >= 2) { const configDirPath = args[validateIndex + 1] || PATH_CONFIG; return this._validate(configDirPath); } // Handle 'login' subcommand before Commander parsing const loginIndex = args.indexOf('login'); if (loginIndex >= 2) { return this._login(args); } // Handle 'logout' subcommand before Commander parsing const logoutIndex = args.indexOf('logout'); if (logoutIndex >= 2) { return this._logout(args); } this._cmd.parse(args); const options = this._cmd.opts(); if (!fs.existsSync(options.configDirPath)) { console.error(`Config directory not found: ${options.configDirPath}\nRun 'trash-cleaner init' to set up your configuration.`); return false; } try { const configStore = this._createConfigStore(options.configDirPath); const client = await this._createEmailClient(configStore, options.service, options.reconfig, options.launch, options.account); const actionLog = new ActionLog(options.configDirPath); const trashCleanerFactory = new TrashCleanerFactory(configStore, client, !options.quiet, !!options.quiet, options.format, actionLog, options.minAge); const trashCleaner = await trashCleanerFactory.getInstance(); if (options.interactive) { await this._runInteractive(trashCleaner); } else { await trashCleaner.cleanTrash(!!options.dryRun); } } catch (err: unknown) { const error = err as Error; if (options.debug) { console.error('An error occurred:', err); } else { console.error(error.message); } return false; } return true; } /** * Initializes the config directory with sample files. */ _initConfig(configDirPath: string): boolean { if (!fs.existsSync(configDirPath)) { fs.mkdirSync(configDirPath, { recursive: true }); console.log(`Created config directory: ${configDirPath}`); } else { console.log(`Config directory already exists: ${configDirPath}`); } let copiedCount = 0; for (const [targetName, sampleName] of Object.entries(SAMPLE_FILES)) { const targetPath = path.join(configDirPath, targetName); const samplePath = path.join(SAMPLE_DIR, sampleName); if (fs.existsSync(targetPath)) { console.log(` Skipped ${targetName} (already exists)`); } else if (!fs.existsSync(samplePath)) { console.log(` Skipped ${targetName} (sample not found)`); } else { fs.copyFileSync(samplePath, targetPath); console.log(` Created ${targetName}`); copiedCount++; } } console.log(''); if (copiedCount > 0) { console.log('Next steps:'); console.log(` 1. Edit ${path.join(configDirPath, 'keywords.yaml')} to configure your keyword rules`); console.log(` 2. Run: trash-cleaner login (saves credentials securely in OS keychain)`); console.log(` or edit IMAP/Gmail/Outlook credential files for file-based setup`); console.log(` 3. Run: trash-cleaner -c ${configDirPath}`); } else { console.log('All config files already exist. Edit them as needed.'); } return true; } /** * Lists active keyword rules from the config. */ async _listRules(configDirPath: string): Promise<boolean> { try { const configStore = new FileSystemConfigStore(configDirPath); const factory = new TrashCleanerFactory(configStore, {} as EmailClient, false); const { keywords } = await factory.readKeywords(); console.log(`Rules loaded from: ${configDirPath}`); console.log(`Total rules: ${keywords.length}`); console.log(''); keywords.forEach((keyword, index) => { const action = keyword.action || 'delete'; const fields = keyword.fields.join(', '); const labels = keyword.labels.join(', '); console.log(` ${index + 1}. /${keyword.value}/`); console.log(` Fields: ${fields} | Labels: ${labels} | Action: ${action}`); }); // Show allowlist if present const allowlist = await factory.readAllowlist(); if (allowlist.length > 0) { console.log(''); console.log(`Allowlist (${allowlist.length} pattern${allowlist.length === 1 ? '' : 's'}):`); allowlist.forEach((pattern, index) => { console.log(` ${index + 1}. /${pattern}/`); }); } } catch (err: unknown) { console.error((err as Error).message); return false; } return true; } /** * Shows the last action batch and offers to undo it. */ async _undo(configDirPath: string, args: string[]): Promise<boolean> { const actionLog = new ActionLog(configDirPath); const batch = actionLog.getLastBatch(); if (!batch) { console.log('No actions to undo.'); return true; } console.log(`\nLast action (${batch.timestamp}):\n`); batch.entries.forEach((entry, i) => { console.log(` ${i + 1}. [${entry.action}] ${entry.from} — ${entry.subject}`); }); console.log(''); const confirmed = await this._confirm( `Restore ${batch.entries.length} email(s)? (y/N) ` ); if (!confirmed) { console.log('Cancelled.'); return true; } // Determine service and account from args const serviceIndex = args.indexOf('-s') !== -1 ? args.indexOf('-s') : args.indexOf('--service'); const service = serviceIndex !== -1 ? args[serviceIndex + 1] : EmailService.IMAP; if (service === EmailService.IMAP) { console.error('Undo is not supported in IMAP mode. Use --service gmail or --service outlook for undo support.'); return false; } const accountIndex = args.indexOf('-a') !== -1 ? args.indexOf('-a') : args.indexOf('--account'); const account = accountIndex !== -1 ? args[accountIndex + 1] : undefined; try { const configStore = this._createConfigStore(configDirPath); const client = await this._createEmailClient(configStore, service!, false, false, account); const emailIds = batch.entries.map(e => e.id); await (client as any).restoreEmails(emailIds); actionLog.removeLastBatch(); console.log(`Restored ${batch.entries.length} email(s).`); } catch (err: unknown) { console.error(`Undo failed: ${(err as Error).message}`); return false; } return true; } /** * Validates configuration files and reports any issues. */ async _validate(configDirPath: string): Promise<boolean> { const issues: ValidationIssue[] = []; let hasErrors = false; // Check config directory exists if (!fs.existsSync(configDirPath)) { console.error(`Config directory not found: ${configDirPath}`); console.log('Run "trash-cleaner init" to create it.'); return false; } // Check keywords config (yaml or json) const keywordsYaml = path.join(configDirPath, 'keywords.yaml'); const keywordsJson = path.join(configDirPath, 'keywords.json'); const keywordsFile = fs.existsSync(keywordsYaml) ? 'keywords.yaml' : 'keywords.json'; if (!fs.existsSync(keywordsYaml) && !fs.existsSync(keywordsJson)) { issues.push({ file: 'keywords.yaml', level: 'error', message: 'File not found (required)' }); hasErrors = true; } else { try { const configStore = new FileSystemConfigStore(configDirPath); const factory = new TrashCleanerFactory(configStore, {} as EmailClient, false); const { keywords } = await factory.readKeywords(); issues.push({ file: keywordsFile, level: 'ok', message: `${keywords.length} rule(s) loaded` }); } catch (err: unknown) { issues.push({ file: keywordsFile, level: 'error', message: (err as Error).message }); hasErrors = true; } } // Check allowlist (optional, yaml or json) const allowlistYaml = path.join(configDirPath, 'allowlist.yaml'); const allowlistJson = path.join(configDirPath, 'allowlist.json'); if (fs.existsSync(allowlistYaml) || fs.existsSync(allowlistJson)) { const allowlistFile = fs.existsSync(allowlistYaml) ? 'allowlist.yaml' : 'allowlist.json'; try { const configStore = new FileSystemConfigStore(configDirPath); const factory = new TrashCleanerFactory(configStore, {} as EmailClient, false); const allowlist = await factory.readAllowlist(); for (const pattern of allowlist) { new RegExp(pattern, 'i'); } issues.push({ file: allowlistFile, level: 'ok', message: `${allowlist.length} pattern(s) loaded` }); } catch (err: unknown) { issues.push({ file: fs.existsSync(allowlistYaml) ? 'allowlist.yaml' : 'allowlist.json', level: 'error', message: (err as Error).message }); hasErrors = true; } } else { issues.push({ file: 'allowlist.yaml', level: 'info', message: 'Not found (optional)' }); } // Check llm-providers (optional, yaml or json) const llmYaml = path.join(configDirPath, 'llm-providers.yaml'); const llmJson = path.join(configDirPath, 'llm-providers.json'); if (fs.existsSync(llmYaml) || fs.existsSync(llmJson)) { const llmFile = fs.existsSync(llmYaml) ? 'llm-providers.yaml' : 'llm-providers.json'; try { const configStore = new FileSystemConfigStore(configDirPath); const factory = new TrashCleanerFactory(configStore, {} as EmailClient, false); const providers = await factory.readLlmProviders(); const count = Object.keys(providers).length; issues.push({ file: llmFile, level: 'ok', message: `${count} provider(s) configured` }); } catch (err: unknown) { issues.push({ file: fs.existsSync(llmYaml) ? 'llm-providers.yaml' : 'llm-providers.json', level: 'error', message: (err as Error).message }); hasErrors = true; } } else { issues.push({ file: 'llm-providers.yaml', level: 'info', message: 'Not found (needed for LLM rules)' }); } // Check credential files for (const credFile of ['imap.credentials.json', 'gmail.credentials.json', 'outlook.credentials.json']) { const credPath = path.join(configDirPath, credFile); if (fs.existsSync(credPath)) { try { JSON.parse(fs.readFileSync(credPath, 'utf8')); issues.push({ file: credFile, level: 'ok', message: 'Valid JSON' }); } catch { issues.push({ file: credFile, level: 'error', message: 'Invalid JSON' }); hasErrors = true; } } else { issues.push({ file: credFile, level: 'info', message: 'Not found (needed for service)' }); } } // Print results console.log(`\nValidating config: ${configDirPath}\n`); for (const issue of issues) { const icon = issue.level === 'ok' ? '✓' : issue.level === 'error' ? '✗' : '–'; console.log(` ${icon} ${issue.file}: ${issue.message}`); } console.log(''); if (hasErrors) { console.log('Validation failed. Fix the errors above.'); } else { console.log('Configuration is valid.'); } return !hasErrors; } /** * Creates a ConfigStore with keychain support for secure credential storage. */ _createConfigStore(configDirPath: string): ConfigStore { const fileStore = new FileSystemConfigStore(configDirPath); return new SecureConfigStore(fileStore); } /** * Creates a readline interface for interactive prompts. */ _createReadlineInterface(): readline.Interface { return readline.createInterface({ input: process.stdin, output: process.stdout }); } /** * Saves credentials to the OS keychain for a service. */ async _login(args: string[]): Promise<boolean> { const service = this._getArgValue(args, '-s', '--service') || EmailService.IMAP; const account = this._getArgValue(args, '-a', '--account') || 'default'; const rl = this._createReadlineInterface(); const ask = (question: string): Promise<string> => new Promise(resolve => rl.question(question, resolve)); try { let credentials: object; let keychainKey: string; switch (service) { case EmailService.IMAP: { const suffix = (!account || account === 'default') ? '' : `.${account}`; keychainKey = `imap.credentials${suffix}.json`; const host = await ask('IMAP host (e.g., imap.gmail.com): '); if (!host.trim()) { console.error('Error: IMAP host is required.'); return false; } const port = await ask('IMAP port (default: 993): '); const user = await ask('Email address: '); if (!user.trim()) { console.error('Error: Email address is required.'); return false; } const password = await ask('App password: '); if (!password.trim()) { console.error('Error: App password is required.'); return false; } const archiveFolder = await ask('Archive folder (default: Archive): '); credentials = { host: host.trim(), port: parseInt(port) || 993, user: user.trim(), password, archiveFolder: archiveFolder.trim() || undefined }; break; } case EmailService.GMAIL: { const suffix = (!account || account === 'default') ? '' : `.${account}`; keychainKey = `gmail.credentials${suffix}.json`; console.log('Paste your Gmail OAuth2 credentials JSON (from Google Cloud Console):'); const json = await ask('> '); if (!json.trim()) { console.error('Error: OAuth2 credentials JSON is required.'); return false; } credentials = JSON.parse(json); break; } case EmailService.OUTLOOK: { const suffix = (!account || account === 'default') ? '' : `.${account}`; keychainKey = `outlook.credentials${suffix}.json`; const clientId = await ask('Client ID: '); if (!clientId.trim()) { console.error('Error: Client ID is required.'); return false; } const tenantId = await ask('Tenant ID: '); if (!tenantId.trim()) { console.error('Error: Tenant ID is required.'); return false; } const aadEndpoint = await ask('AAD endpoint (default: https://login.microsoftonline.com/): '); const graphEndpoint = await ask('Graph endpoint (default: https://graph.microsoft.com/): '); credentials = { client_id: clientId.trim(), tenant_id: tenantId.trim(), aad_endpoint: aadEndpoint.trim() || 'https://login.microsoftonline.com/', graph_endpoint: graphEndpoint.trim() || 'https://graph.microsoft.com/' }; break; } default: console.error(`Unknown service: ${service}`); return false; } const { SecureConfigStore: SC } = await import('./store/secure-config-store.js'); const store = new SC({ get: () => null, put: () => {} } as any); await store.putJson(keychainKey!, credentials); console.log(`\n✓ Credentials saved to OS keychain for ${service} (account: ${account})`); console.log(' Your credentials are stored securely and will not be written to disk.'); if (!fs.existsSync(PATH_CONFIG)) { console.log(`\nNext step: run 'trash-cleaner init' to create your config directory with keyword rules.`); } return true; } catch (err: unknown) { console.error(`Login failed: ${(err as Error).message}`); return false; } finally { rl.close(); } } /** * Removes credentials from the OS keychain for a service. */ async _logout(args: string[]): Promise<boolean> { const service = this._getArgValue(args, '-s', '--service') || EmailService.IMAP; const account = this._getArgValue(args, '-a', '--account') || 'default'; const suffix = (!account || account === 'default') ? '' : `.${account}`; const { SecureConfigStore: SC } = await import('./store/secure-config-store.js'); const store = new SC({ get: () => null, put: () => {} } as any); const keys: string[] = []; switch (service) { case EmailService.IMAP: keys.push(`imap.credentials${suffix}.json`); break; case EmailService.GMAIL: keys.push(`gmail.credentials${suffix}.json`); keys.push(`gmail.token${suffix}.json`); break; case EmailService.OUTLOOK: keys.push(`outlook.credentials${suffix}.json`); keys.push(`outlook.token${suffix}.json`); break; default: console.error(`Unknown service: ${service}`); return false; } let removed = 0; for (const key of keys) { if (await store.remove(key)) { removed++; } } if (removed > 0) { console.log(`✓ Removed ${removed} credential(s) from OS keychain for ${service} (account: ${account})`); } else { console.log(`No keychain credentials found for ${service} (account: ${account})`); } return true; } /** * Gets a CLI argument value by short or long flag. */ _getArgValue(args: string[], shortFlag: string, longFlag: string): string | undefined { const index = args.indexOf(shortFlag) !== -1 ? args.indexOf(shortFlag) : args.indexOf(longFlag); return index !== -1 ? args[index + 1] : undefined; } /** * Creates an instance of email client by service name. */ async _createEmailClient(configStore: ConfigStore, service: string, reconfig: boolean, launch: boolean, account: string = 'default'): Promise<EmailClient> { let factory = null; switch (service) { case EmailService.IMAP: factory = new ImapClientFactory(configStore, account); break; case EmailService.GMAIL: factory = new GmailClientFactory(configStore, account); break; case EmailService.OUTLOOK: factory = new OutlookClientFactory(configStore, account); break; default: throw new Error(`Email service '${service}' not yet implemented.`); } return await factory.getInstance(reconfig, launch); } /** * Runs the cleaner in interactive mode: preview matches, then confirm. */ async _runInteractive(trashCleaner: TrashCleaner): Promise<void> { const emails = await trashCleaner.findTrash(); if (emails.length === 0) { console.log('No trash emails found.'); return; } console.log(`\nFound ${emails.length} trash email(s):\n`); const confirmed = []; let bulk: 'yes' | 'no' | null = null; for (let i = 0; i < emails.length; i++) { const email = emails[i]!; const action = email._action || 'delete'; const rule = email._rule ? ` Rule: ${email._rule}\n` : ''; console.log(` ${i + 1}/${emails.length} [${action}] ${email.from} — ${email.subject}`); if (rule) { console.log(rule.trimEnd()); } if (bulk === 'yes') { confirmed.push(email); continue; } if (bulk === 'no') { continue; } const answer = await this._promptAction(` ${action}? (y/n/Y=yes all/N=no all) `); if (answer === 'yes-all') { bulk = 'yes'; confirmed.push(email); } else if (answer === 'no-all') { bulk = 'no'; } else if (answer === 'yes') { confirmed.push(email); } } if (confirmed.length === 0) { console.log('No emails selected.'); return; } console.log(`\nProcessing ${confirmed.length} of ${emails.length} email(s)...`); await trashCleaner.processEmails(confirmed); console.log('Done.'); } /** * Prompts the user for an interactive action choice. * Accepts: y (yes), n (no), Y (yes all), N (no all). */ _promptAction(question: string): Promise<string> { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise(resolve => { rl.question(question, (answer) => { rl.close(); const trimmed = answer.trim(); if (trimmed === 'Y') { resolve('yes-all'); } else if (trimmed === 'N') { resolve('no-all'); } else if (trimmed.toLowerCase() === 'y' || trimmed.toLowerCase() === 'yes') { resolve('yes'); } else { resolve('no'); } }); }); } /** * Prompts the user for yes/no confirmation. */ _confirm(question: string): Promise<boolean> { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise(resolve => { rl.question(question, (answer) => { rl.close(); resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); }); }); } } export { Cli };