UNPKG

trash-cleaner

Version:

Finds and deletes trash email in the mailbox

972 lines (776 loc) 31.9 kB
import sinon from 'sinon'; import { assert } from 'chai'; import { Email } from '../lib/client/email-client.js'; import { ProgressReporter } from '../lib/reporter/progress-reporter.js'; import { TrashKeyword, TrashCleaner, TrashCleanerFactory, LlmTrashRule, KeywordTrashRule } from '../lib/trash-cleaner.js'; describe('TrashKeyword', () => { describe('constructor', () => { it('throws when value is not set', () => { assert.throws(() => new TrashKeyword(null as any, ['*'], ['spam']), /Invalid keyword/); }); it('throws when fields are not set', () => { assert.throws(() => new TrashKeyword('apple', null as any, ['*']), /Invalid keyword/); }); it('throws when labels are not set', () => { assert.throws(() => new TrashKeyword('apple', ['*'], null as any), /Invalid keyword/); }); it('does not throw when value and labels are set', () => { assert.doesNotThrow(() => new TrashKeyword('apple', ['*'], ['spam'])); }); it('defaults action to delete', () => { const keyword = new TrashKeyword('apple', ['*'], ['spam']); assert.equal(keyword.action, 'delete'); }); it('accepts valid action', () => { const keyword = new TrashKeyword('apple', ['*'], ['spam'], 'archive'); assert.equal(keyword.action, 'archive'); }); it('throws for invalid action', () => { assert.throws(() => new TrashKeyword('apple', ['*'], ['spam'], 'invalid'), /Invalid action/); }); it('defaults type to keyword', () => { const keyword = new TrashKeyword('apple', ['*'], ['spam']); assert.equal(keyword.type, 'keyword'); }); it('accepts llm type with provider', () => { const keyword = new TrashKeyword('marketing email', ['*'], ['*'], 'delete', 'llm', undefined, 'claude'); assert.equal(keyword.type, 'llm'); assert.equal(keyword.llm, 'claude'); }); }); }); describe('TrashCleaner', () => { let client: any, email: Email, reporter: ProgressReporter; before(() => { sinon.stub(console, 'log'); }); after(() => { (console.log as sinon.SinonStub).restore(); }); beforeEach(() => { email = new Email(); client = { getUnreadEmails: sinon.stub().returns([email]), deleteEmails: sinon.stub(), archiveEmails: sinon.stub(), markAsReadEmails: sinon.stub() }; reporter = new ProgressReporter(); }); describe('cleanTrash', () => { [ { match: 'keyword', value: 'orange', fields: ['*'], labels: ['spam'] }, { match: 'field', value: 'apple', fields: ['subject'], labels: ['spam'] }, { match: 'label', value: 'apple', fields: ['*'], labels: ['inbox'] }, ].forEach(data => it(`does not find spam when ${data.match} does not match`, async () => { email.body = 'apple'; email.labels = ['spam']; const cleaner = new TrashCleaner(client, [{ value: data.value, fields: data.fields, labels: data.labels }], reporter); await cleaner.cleanTrash(); sinon.assert.notCalled(client.deleteEmails); })); it('uses regex', async () => { email.body = 'orange'; email.labels = ['spam']; const cleaner = new TrashCleaner(client, [{ value: 'mango|apple|orange', fields: ['*'], labels: ['spam'] }], reporter); await cleaner.cleanTrash(); sinon.assert.calledWith(client.deleteEmails, [email]); }); it('finds spam with diacritics', async () => { email.body = 'Ápplé'; email.labels = ['spam']; const cleaner = new TrashCleaner(client, [{ value: 'apple', fields: ['*'], labels: ['spam'] }], reporter); await cleaner.cleanTrash(); sinon.assert.calledWith(client.deleteEmails, [email]); }); it('finds spam with wildcard label', async () => { email.body = 'apple'; email.labels = ['spam']; const cleaner = new TrashCleaner(client, [{ value: 'apple', fields: ['*'], labels: ['*'] }], reporter); await cleaner.cleanTrash(); sinon.assert.calledWith(client.deleteEmails, [email]); }); it('succeeds when there are no emails', async () => { client.getUnreadEmails.returns([]); const cleaner = new TrashCleaner(client, [{ value: 'apple', fields: ['*'], labels: ['inbox'] }], reporter); await cleaner.cleanTrash(); sinon.assert.notCalled(client.deleteEmails); }); it('is case insensitive', async () => { client.getUnreadEmails.returns([email]); const testData = [ { keyword: 'apple', label: 'spam', emailBody: 'APPLE', emailLabel: 'SPAM' }, { keyword: 'APPLE', label: 'spam', emailBody: 'apple', emailLabel: 'spam' }, { keyword: 'apple', label: 'SPAM', emailBody: 'apple', emailLabel: 'spam' }, ]; for (const data of testData) { email.body = data.emailBody; email.labels = [data.emailLabel]; const cleaner = new TrashCleaner(client, [{ value: data.keyword, fields: ['*'], labels: [data.label] }], reporter); await cleaner.cleanTrash(); sinon.assert.calledWith(client.deleteEmails, [email]); client.deleteEmails.reset(); } }); ['from', 'subject', 'snippet', 'body'].forEach(field => it(`finds spam in ${field} field`, async () => { (email as any)[field] = 'apple'; email.labels = ['spam']; const cleaner = new TrashCleaner(client, [{ value: 'apple', fields: [field], labels: ['spam'] }], reporter); await cleaner.cleanTrash(); sinon.assert.calledWith(client.deleteEmails, [email]); })); it('archives emails when action is archive', async () => { email.body = 'newsletter content'; email.labels = ['inbox']; const cleaner = new TrashCleaner(client, [{ value: 'newsletter', fields: ['*'], labels: ['*'], action: 'archive' }], reporter); await cleaner.cleanTrash(); sinon.assert.calledWith(client.archiveEmails, [email]); sinon.assert.notCalled(client.deleteEmails); }); it('marks emails as read when action is mark-as-read', async () => { email.body = 'notification update'; email.labels = ['inbox']; const cleaner = new TrashCleaner(client, [{ value: 'notification', fields: ['*'], labels: ['*'], action: 'mark-as-read' }], reporter); await cleaner.cleanTrash(); sinon.assert.calledWith(client.markAsReadEmails, [email]); sinon.assert.notCalled(client.deleteEmails); }); it('groups emails by action and processes each group', async () => { const email2 = new Email(); email.body = 'casino spam'; email.labels = ['spam']; email2.body = 'newsletter digest'; email2.labels = ['inbox']; client.getUnreadEmails.returns([email, email2]); const cleaner = new TrashCleaner(client, [ { value: 'casino', fields: ['*'], labels: ['*'], action: 'delete' }, { value: 'newsletter', fields: ['*'], labels: ['*'], action: 'archive' } ], reporter); await cleaner.cleanTrash(); sinon.assert.calledWith(client.deleteEmails, [email]); sinon.assert.calledWith(client.archiveEmails, [email2]); }); it('does not execute actions in dry-run mode', async () => { email.body = 'newsletter content'; email.labels = ['inbox']; const cleaner = new TrashCleaner(client, [{ value: 'newsletter', fields: ['*'], labels: ['*'], action: 'archive' }], reporter); await cleaner.cleanTrash(true /*dryRun*/); sinon.assert.notCalled(client.archiveEmails); sinon.assert.notCalled(client.deleteEmails); }); }); describe('allowlist', () => { it('protects allowlisted sender from deletion', async () => { email.body = 'casino spam'; email.from = 'boss@example.com'; email.labels = ['spam']; const cleaner = new TrashCleaner(client, [{ value: 'casino', fields: ['*'], labels: ['*'] }], reporter, ['boss@example\\.com']); await cleaner.cleanTrash(); sinon.assert.notCalled(client.deleteEmails); }); it('allows non-allowlisted sender to be deleted', async () => { email.body = 'casino spam'; email.from = 'spammer@evil.com'; email.labels = ['spam']; const cleaner = new TrashCleaner(client, [{ value: 'casino', fields: ['*'], labels: ['*'] }], reporter, ['boss@example\\.com']); await cleaner.cleanTrash(); sinon.assert.calledWith(client.deleteEmails, [email]); }); it('allowlist patterns are case-insensitive', async () => { email.body = 'promo offer'; email.from = 'BOSS@Example.COM'; email.labels = ['inbox']; const cleaner = new TrashCleaner(client, [{ value: 'promo', fields: ['*'], labels: ['*'] }], reporter, ['boss@example\\.com']); await cleaner.cleanTrash(); sinon.assert.notCalled(client.deleteEmails); }); it('supports regex patterns in allowlist', async () => { email.body = 'newsletter'; email.from = 'news@trusted-domain.org'; email.labels = ['inbox']; const cleaner = new TrashCleaner(client, [{ value: 'newsletter', fields: ['*'], labels: ['*'] }], reporter, ['@trusted-domain\\.org']); await cleaner.cleanTrash(); sinon.assert.notCalled(client.deleteEmails); }); it('works with empty allowlist', async () => { email.body = 'casino'; email.from = 'anyone@test.com'; email.labels = ['spam']; const cleaner = new TrashCleaner(client, [{ value: 'casino', fields: ['*'], labels: ['*'] }], reporter, []); await cleaner.cleanTrash(); sinon.assert.calledWith(client.deleteEmails, [email]); }); }); describe('minAge filter', () => { it('skips emails newer than minAge', async () => { email.body = 'casino offer'; email.labels = ['spam']; email.date = new Date(); // now — too new const cleaner = new TrashCleaner(client, [{ value: 'casino', fields: ['*'], labels: ['*'] }], reporter, [], null, 7); await cleaner.cleanTrash(); sinon.assert.notCalled(client.deleteEmails); }); it('includes emails older than minAge', async () => { email.body = 'casino offer'; email.labels = ['spam']; email.date = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); // 10 days ago const cleaner = new TrashCleaner(client, [{ value: 'casino', fields: ['*'], labels: ['*'] }], reporter, [], null, 7); await cleaner.cleanTrash(); sinon.assert.calledOnce(client.deleteEmails); }); it('includes emails when minAge is not set', async () => { email.body = 'casino offer'; email.labels = ['spam']; email.date = new Date(); // now — but no age filter const cleaner = new TrashCleaner(client, [{ value: 'casino', fields: ['*'], labels: ['*'] }], reporter); await cleaner.cleanTrash(); sinon.assert.calledOnce(client.deleteEmails); }); it('includes emails with no date when minAge is set', async () => { email.body = 'casino offer'; email.labels = ['spam']; email.date = null as any; const cleaner = new TrashCleaner(client, [{ value: 'casino', fields: ['*'], labels: ['*'] }], reporter, [], null, 7); await cleaner.cleanTrash(); sinon.assert.calledOnce(client.deleteEmails); }); }); describe('error handling', () => { it('throws when getUnreadEmails fails', async () => { client.getUnreadEmails.rejects(new Error('API timeout')); const cleaner = new TrashCleaner(client, [{ value: 'test', fields: ['*'], labels: ['*'] }], reporter); try { await cleaner.cleanTrash(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /Failed to get trash emails/); } }); }); describe('filterTrashEmails', () => { it('returns matching emails without fetching', async () => { email.body = 'casino offer'; email.labels = ['spam']; const cleaner = new TrashCleaner(client, [{ value: 'casino', fields: ['*'], labels: ['*'] }], reporter); const result = await cleaner.filterTrashEmails([email]); assert.deepEqual(result, [email]); }); it('normalizes diacritics before matching', async () => { email.body = 'cásìnó'; email.labels = ['spam']; const cleaner = new TrashCleaner(client, [{ value: 'casino', fields: ['*'], labels: ['*'] }], reporter); const result = await cleaner.filterTrashEmails([email]); assert.deepEqual(result, [email]); }); it('skips emails older than lastRun when seenCache is set', async () => { email.body = 'casino offer'; email.labels = ['spam']; email.date = new Date('2026-05-10T08:00:00Z'); const seenCache = { isSeen: sinon.stub().returns(true) }; const cleaner = new TrashCleaner(client, [{ value: 'casino', fields: ['*'], labels: ['*'] }], reporter, [], null, null, seenCache); const result = await cleaner.filterTrashEmails([email]); assert.equal(result.length, 0); }); it('evaluates emails newer than lastRun', async () => { email.body = 'casino offer'; email.labels = ['spam']; email.date = new Date('2026-05-13T08:00:00Z'); const seenCache = { isSeen: sinon.stub().returns(false) }; const cleaner = new TrashCleaner(client, [{ value: 'casino', fields: ['*'], labels: ['*'] }], reporter, [], null, null, seenCache); const result = await cleaner.filterTrashEmails([email]); assert.deepEqual(result, [email]); }); }); }); describe('TrashCleanerFactory', () => { describe('readKeywords', () => { it('parses keywords from config store', async () => { const configStore = { getJson: sinon.stub().returns([ { value: 'casino', fields: 'subject,body', labels: 'spam' } ]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); const { keywords } = await factory.readKeywords(); assert.equal(keywords.length, 1); assert.equal(keywords[0].value, 'casino'); assert.deepEqual(keywords[0].fields, ['subject', 'body']); assert.deepEqual(keywords[0].labels, ['spam']); }); it('uses wildcard default when fields are missing', async () => { const configStore = { getJson: sinon.stub().returns([ { value: 'test' } ]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); const { keywords } = await factory.readKeywords(); assert.deepEqual(keywords[0].fields, ['*']); assert.deepEqual(keywords[0].labels, ['*']); }); }); describe('splitAndTrim', () => { it('splits comma-separated values', () => { const factory = new TrashCleanerFactory({} as any, {} as any, false); const result = factory.splitAndTrim('a, b, c', ',', '*'); assert.deepEqual(result, ['a', 'b', 'c']); }); it('uses default when value is null', () => { const factory = new TrashCleanerFactory({} as any, {} as any, false); const result = factory.splitAndTrim(null, ',', '*'); assert.deepEqual(result, ['*']); }); it('filters empty tokens', () => { const factory = new TrashCleanerFactory({} as any, {} as any, false); const result = factory.splitAndTrim('a,,b', ',', '*'); assert.deepEqual(result, ['a', 'b']); }); }); describe('getInstance', () => { it('returns a TrashCleaner instance', async () => { const configStore: any = { getJson: sinon.stub(), putJson: sinon.stub() }; configStore.getJson.withArgs('keywords.json').returns([ { value: 'test', fields: '*', labels: 'spam' } ]); configStore.getJson.withArgs('allowlist.json').returns(['safe@test.com']); configStore.getJson.withArgs('seen.json').returns(null); const factory = new TrashCleanerFactory(configStore, {} as any, false); const cleaner = await factory.getInstance(); assert.instanceOf(cleaner, TrashCleaner); }); }); describe('readAllowlist', () => { it('reads allowlist from config store', async () => { const configStore = { getJson: sinon.stub().withArgs('allowlist.json').returns(['sender@test.com']) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); const allowlist = await factory.readAllowlist(); assert.deepEqual(allowlist, ['sender@test.com']); }); it('returns empty array when file does not exist', async () => { const configStore = { getJson: sinon.stub().withArgs('allowlist.json').throws(new Error('File not found')) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); const allowlist = await factory.readAllowlist(); assert.deepEqual(allowlist, []); }); it('throws when file contains invalid JSON', async () => { const configStore = { getJson: sinon.stub().withArgs('allowlist.json') .throws(new Error('Unexpected token in JSON')) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); try { await factory.readAllowlist(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /Unexpected token/); } }); it('throws when allowlist is not an array', async () => { const configStore = { getJson: sinon.stub().withArgs('allowlist.json').returns({ sender: 'test' }) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); try { await factory.readAllowlist(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /must contain a JSON array/); } }); }); describe('config validation', () => { it('rejects non-array config', async () => { const configStore = { getJson: sinon.stub().returns({}) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); try { await factory.readKeywords(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /must contain a JSON array/); } }); it('returns empty array for empty keywords config', async () => { const configStore = { getJson: sinon.stub().returns([]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); const { keywords } = await factory.readKeywords(); assert.deepEqual(keywords, []); }); it('returns empty array when keywords.json does not exist', async () => { const configStore = { getJson: sinon.stub().rejects(new Error('ENOENT: no such file')) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); const { keywords } = await factory.readKeywords(); assert.deepEqual(keywords, []); }); it('rejects entry without value', async () => { const configStore = { getJson: sinon.stub().returns([{ fields: '*' }]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); try { await factory.readKeywords(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /missing a valid "value" field/); } }); it('rejects non-string fields', async () => { const configStore = { getJson: sinon.stub().returns([{ value: 'test', fields: ['*'] }]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); try { await factory.readKeywords(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /"fields" must be a comma-separated string/); } }); it('rejects non-string labels', async () => { const configStore = { getJson: sinon.stub().returns([{ value: 'test', labels: ['spam'] }]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); try { await factory.readKeywords(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /"labels" must be a comma-separated string/); } }); it('rejects invalid action', async () => { const configStore = { getJson: sinon.stub().returns([{ value: 'test', action: 'explode' }]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); try { await factory.readKeywords(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /invalid action "explode"/); } }); it('accepts valid config with all fields', async () => { const configStore = { getJson: sinon.stub().returns([ { value: 'test', fields: 'subject', labels: 'spam', action: 'archive' } ]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); const { keywords } = await factory.readKeywords(); assert.equal(keywords.length, 1); assert.equal(keywords[0].action, 'archive'); }); it('includes index in error for bad entry', async () => { const configStore = { getJson: sinon.stub().returns([ { value: 'ok' }, { value: '' } ]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); try { await factory.readKeywords(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /index 1/); } }); }); }); describe('LlmTrashRule', () => { const mockProvider = { command: 'echo', args: ['{{prompt}}'] }; it('stores label and action from keyword', () => { const keyword = new TrashKeyword('marketing email', ['*'], ['*'], 'archive', 'llm', undefined, 'claude'); const rule = new LlmTrashRule(keyword, mockProvider); assert.equal(rule.label, 'marketing email'); assert.equal(rule.action, 'archive'); assert.deepEqual(rule.labels, ['*']); }); it('defaults action to delete', () => { const keyword = new TrashKeyword('spam content', ['*'], ['inbox'], undefined, 'llm', undefined, 'claude'); const rule = new LlmTrashRule(keyword, mockProvider); assert.equal(rule.action, 'delete'); }); it('stores provider config', () => { const keyword = new TrashKeyword('promo', ['*'], ['*'], 'delete', 'llm', undefined, 'claude'); const rule = new LlmTrashRule(keyword, mockProvider); assert.equal(rule.provider, mockProvider); }); }); describe('TrashCleanerFactory with LLM rules', () => { it('parses llm type from config', async () => { const configStore = { getJson: sinon.stub().returns([ { value: 'marketing email', labels: '*', type: 'llm', llm: 'claude', action: 'archive' } ]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); const { keywords } = await factory.readKeywords(); assert.equal(keywords.length, 1); assert.equal(keywords[0].type, 'llm'); assert.equal(keywords[0].value, 'marketing email'); assert.equal(keywords[0].action, 'archive'); assert.equal(keywords[0].llm, 'claude'); }); it('defaults type to keyword when not specified', async () => { const configStore = { getJson: sinon.stub().returns([ { value: 'casino', fields: 'subject', labels: 'spam' } ]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); const { keywords } = await factory.readKeywords(); assert.equal(keywords[0].type, 'keyword'); }); it('rejects invalid type', async () => { const configStore = { getJson: sinon.stub().returns([ { value: 'test', type: 'invalid' } ]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); try { await factory.readKeywords(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /invalid type/); } }); it('requires llm field for type llm', async () => { const configStore = { getJson: sinon.stub().returns([ { value: 'marketing', type: 'llm', labels: '*' } ]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); try { await factory.readKeywords(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /LLM rules require a "llm" field/); } }); it('creates LlmTrashRule for llm type keywords', () => { const reporter = new ProgressReporter(); const keywords = [ new TrashKeyword('marketing', ['*'], ['*'], 'archive', 'llm', undefined, 'claude'), new TrashKeyword('casino', ['*'], ['*'], 'delete', 'keyword') ]; const llmProviders = { claude: { command: 'claude', args: ['--print', '{{prompt}}'] } }; const cleaner = new TrashCleaner({} as any, keywords, reporter, [], null, null, null, llmProviders); assert.instanceOf((cleaner as any)._rules[0], LlmTrashRule); assert.notInstanceOf((cleaner as any)._rules[1], LlmTrashRule); }); it('throws when LLM provider not found', () => { const reporter = new ProgressReporter(); const keywords = [ new TrashKeyword('marketing', ['*'], ['*'], 'archive', 'llm', undefined, 'missing') ]; assert.throws( () => new TrashCleaner({} as any, keywords, reporter, [], null, null, null, {}), /LLM provider "missing" not found/ ); }); }); describe('TrashCleanerFactory.readLlmProviders', () => { it('reads providers from config', async () => { const configStore = { getJson: sinon.stub().resolves({ claude: { command: 'claude', args: ['--print', '{{prompt}}'] } }) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); const providers = await factory.readLlmProviders(); assert.deepEqual(providers.claude, { command: 'claude', args: ['--print', '{{prompt}}'] }); }); it('returns empty object when file not found', async () => { const configStore = { getJson: sinon.stub().rejects(new Error('ENOENT')) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); const providers = await factory.readLlmProviders(); assert.deepEqual(providers, {}); }); it('rejects provider missing command', async () => { const configStore = { getJson: sinon.stub().resolves({ bad: { args: ['{{prompt}}'] } }) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); try { await factory.readLlmProviders(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /missing a valid "command"/); } }); it('rejects provider missing args', async () => { const configStore = { getJson: sinon.stub().resolves({ bad: { command: 'echo' } }) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); try { await factory.readLlmProviders(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /missing an "args" array/); } }); it('rejects provider args without prompt placeholder', async () => { const configStore = { getJson: sinon.stub().resolves({ bad: { command: 'echo', args: ['hello'] } }) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); try { await factory.readLlmProviders(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /must contain a "{{prompt}}" placeholder/); } }); }); describe('Rule title', () => { it('KeywordTrashRule uses title from keyword when provided', () => { const keyword = new TrashKeyword('casino', ['*'], ['*'], 'delete', 'keyword', 'Casino spam'); const rule = new KeywordTrashRule(keyword); assert.equal(rule.title, 'Casino spam'); }); it('KeywordTrashRule defaults title to value when not provided', () => { const keyword = new TrashKeyword('casino', ['*'], ['*']); const rule = new KeywordTrashRule(keyword); assert.equal(rule.title, 'casino'); }); it('LlmTrashRule uses title from keyword when provided', () => { const keyword = new TrashKeyword('marketing email', ['*'], ['*'], 'archive', 'llm', 'Marketing emails', 'claude'); const rule = new LlmTrashRule(keyword, { command: 'echo', args: ['{{prompt}}'] }); assert.equal(rule.title, 'Marketing emails'); }); it('LlmTrashRule defaults title to value when not provided', () => { const keyword = new TrashKeyword('marketing email', ['*'], ['*'], 'archive', 'llm', undefined, 'claude'); const rule = new LlmTrashRule(keyword, { command: 'echo', args: ['{{prompt}}'] }); assert.equal(rule.title, 'marketing email'); }); it('_isTrashEmail sets _rule on matched email', async () => { const email = { from: 'test', subject: 'casino offer', snippet: '', body: 'casino offer', labels: ['spam'] } as any; const keyword = new TrashKeyword('casino', ['*'], ['*'], 'delete', 'keyword', 'Casino spam'); const reporter = new ProgressReporter(); const cleaner = new TrashCleaner({} as any, [keyword], reporter); const result = await cleaner.filterTrashEmails([email]); assert.equal(result.length, 1); assert.equal(result[0]._rule, 'Casino spam'); }); it('TrashKeyword stores title', () => { const keyword = new TrashKeyword('test', ['*'], ['*'], 'delete', 'keyword', 'My Rule'); assert.equal(keyword.title, 'My Rule'); }); it('TrashKeyword title defaults to undefined when not provided', () => { const keyword = new TrashKeyword('test', ['*'], ['*']); assert.equal(keyword.title, undefined); }); it('readKeywords parses title from config', async () => { const configStore = { getJson: sinon.stub().returns([ { value: 'casino', fields: '*', labels: 'spam', title: 'Casino spam' } ]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); const { keywords } = await factory.readKeywords(); assert.equal(keywords[0].title, 'Casino spam'); }); it('validation rejects non-string title', async () => { const configStore = { getJson: sinon.stub().returns([ { value: 'test', title: 123 } ]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); try { await factory.readKeywords(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /"title" must be a non-empty string/); } }); it('validation rejects empty title', async () => { const configStore = { getJson: sinon.stub().returns([ { value: 'test', title: ' ' } ]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); try { await factory.readKeywords(); assert.fail('should throw'); } catch (err: any) { assert.match(err.message, /"title" must be a non-empty string/); } }); it('validation accepts missing title', async () => { const configStore = { getJson: sinon.stub().returns([ { value: 'test' } ]) }; const factory = new TrashCleanerFactory(configStore as any, {} as any, false); const { keywords } = await factory.readKeywords(); assert.equal(keywords.length, 1); }); });