char/imapfilter-config

git clone https://git.t4t.associates/char/imapfilter-config

Charlotte Somrewrite6a9e105

main
9.7 KiB127 linesraw
1local cases = {
2    { name = 'shipping', from = 'Amazon <shipment-tracking@amazon.co.uk>', subject = 'Dispatched: your order', folder = 'notifications' },
3    { name = 'bare mixed-case sender', from = 'SHIPMENT-TRACKING@AMAZON.CO.UK', subject = 'Out for delivery', folder = 'notifications' },
4    { name = 'receipt beats mailing list', from = 'Apple <no_reply@email.apple.com>', subject = 'Your receipt from Apple.', ['list-unsubscribe'] = '<https://example.invalid>', folder = 'notifications' },
5    { name = 'Stripe receipt', from = 'Example <receipts+acct_example@stripe.com>', subject = 'Your Example receipt [#1234]', folder = 'notifications' },
6    { name = 'hosting payment', from = 'Host <email@client.host4fun.com>', subject = 'Payment Confirmation : Invoice #123', folder = 'notifications' },
7    { name = 'support conversation', from = 'Host <email@client.host4fun.com>', subject = '[Ticket ID: 123] Service Unavailable?' },
8    { name = 'bank statement', from = 'Monzo <hello@email.monzo.com>', subject = 'Your September statement', folder = 'notifications' },
9    { name = 'bank account change', from = 'Monzo <hello@email.monzo.com>', subject = 'Your account details changed' },
10    { name = 'Bandcamp release', from = 'Bandcamp <noreply@bandcamp.com>', subject = 'New release from an artist', folder = 'automated mail' },
11    { name = 'Bandcamp purchase', from = 'Bandcamp <noreply@bandcamp.com>', subject = 'Thank you!', folder = 'notifications' },
12    { name = 'Ableton beta', from = 'Live 12 <beta@beta.ableton.com>', subject = 'We have just released Live 12.5b1', folder = 'automated mail' },
13    { name = 'policy update', from = 'service@example.invalid', subject = 'Changes to our User Agreement', folder = 'automated mail' },
14    { name = 'mailing list', from = 'newsletter@example.invalid', subject = 'Weekly roundup', ['list-id'] = 'weekly.example.invalid', folder = 'automated mail' },
15    { name = 'bulk precedence', from = 'newsletter@example.invalid', subject = 'Monthly roundup', precedence = 'bulk', folder = 'automated mail' },
16    { name = 'non-bulk precedence', from = 'person@example.invalid', subject = 'Hello', precedence = 'normal' },
17    { name = 'login notification', from = 'service@example.invalid', subject = 'You signed in from a new device', folder = 'notifications' },
18    { name = 'password reset', from = 'Steam <noreply@steampowered.com>', subject = 'Reset your password', ['list-unsubscribe'] = '<https://example.invalid>' },
19    { name = 'verification code', from = 'Wise <noreply@wise.com>', subject = 'Your verification code' },
20    { name = 'magic link', from = 'service@example.invalid', subject = 'Your secure link is here', ['list-id'] = 'service.example.invalid' },
21    { name = 'failed payment', from = 'Wise <noreply@wise.com>', subject = 'Payment failed' },
22    { name = 'Stripe payment failure', from = 'Example <invoice+statements+acct_example@stripe.com>', subject = 'Payment failed for your invoice' },
23    { name = 'domain expiration', from = 'Cloudflare <noreply@notify.cloudflare.com>', subject = 'Domain Expiration Notification', ['list-unsubscribe'] = '<https://example.invalid>' },
24    { name = 'security warning', from = 'Steam <noreply@steampowered.com>', subject = 'Suspicious activity: new device' },
25    { name = 'breach newsletter', from = 'service@example.invalid', subject = 'Notice of data breach', ['list-id'] = 'service.example.invalid' },
26    { name = 'flagged receipt', from = 'Apple <no_reply@email.apple.com>', subject = 'Your receipt from Apple.', flagged = true },
27    { name = 'confirmed phishing', from = 'Mail Delivery System <vada@servicedive.com>', subject = 'Verify your email or be blocked', ['list-unsubscribe'] = '<https://example.invalid>', folder = 'Junk' },
28    { name = 'flagged spam', from = 'vada@servicedive.com', subject = 'Verify your email', flagged = true },
29    { name = 'sender suffix lookalike', from = 'orders@asos.com.attacker.invalid', subject = 'Your order' },
30    { name = 'sender prefix lookalike', from = 'not-orders@asos.com', subject = 'Your order' },
31    { name = 'unreviewed invoice', from = 'accounts@example.invalid', subject = 'Your invoice is available' },
32    { name = 'personal conversation', from = 'friend@example.invalid', subject = 'Want to catch up?' },
33    { name = 'rotating spam mailbox', from = 'New Person <random.123@devthusiastapp.com>', subject = 'Hello', folder = 'Junk' },
34    { name = 'spam subdomain', from = 'Campaign <random@outreach.eu.devthusiaststudio.com>', subject = 'Hello', folder = 'Junk' },
35    { name = 'bare mixed-case spam domain', from = 'RANDOM@DEVTHUSIASTAPP.COM', subject = 'Hello', folder = 'Junk' },
36    { name = 'other dedicated spam domain', from = 'new-person@sendnovada.com', subject = 'Hello', folder = 'Junk' },
37    { name = 'flagged spam domain', from = 'random@devthusiastapp.com', subject = 'Hello', flagged = true },
38    { name = 'domain suffix lookalike', from = 'person@devthusiastapp.com.example.invalid', subject = 'Hello' },
39    { name = 'domain prefix lookalike', from = 'person@notdevthusiastapp.com', subject = 'Hello' },
40    { name = 'domain in display name', from = '"random@devthusiastapp.com" <friend@example.invalid>', subject = 'Hello' },
41    { name = 'domain in local part', from = 'devthusiastapp.com@example.invalid', subject = 'Hello' },
42    { name = 'exact sender in display name', from = '"vada@servicedive.com" <friend@example.invalid>', subject = 'Hello' },
43    { name = 'unrelated Gmail sender', from = 'friend@gmail.com', subject = 'Hello' },
44    { name = 'unrelated Tiscali sender', from = 'friend@tiscali.it', subject = 'Hello' },
45    { name = 'unreviewed mailbox on possibly compromised domain', from = 'person@club-de-race.com', subject = 'Hello' },
46    { name = 'Devthusiast rotating domain', from = 'random@new-campaign.invalid', subject = 'Re: Gifted Invite Enclosed (Github Community Partnership)', body = 'Your lifetime membership to Devthusiast awaits.', folder = 'Junk' },
47    { name = 'campaign subject without brand', from = 'person@example.invalid', subject = 'Gifted Invite Enclosed (Github Community Partnership)', body = 'A different message.' },
48    { name = 'brand mention without campaign subject', from = 'friend@example.invalid', subject = 'Spam filters', body = 'Have you heard of devthusiast?' },
49    { name = 'generic partnership', from = 'person@example.invalid', subject = 'GitHub partnership', body = 'Would you like to collaborate?' },
50    { name = 'flagged campaign', from = 'random@new-campaign.invalid', subject = 'Gifted Invite Enclosed (Github Community Partnership)', body = 'Welcome to devthusiast.', flagged = true },
51    { name = 'decoded MIME subject', from = 'Windows <Windowsinsiderprogram@e-mails.microsoft.com>', subject = 'Action required: verify your account', raw_subject = '=?UTF-8?B?QWN0aW9uIHJlcXVpcmVkOiB2ZXJpZnkgeW91ciBhY2NvdW50?=' },
52}
53
54for _, dry_run in ipairs({ false, true }) do
55    local moved, preview = {}, {}
56    local inbox = setmetatable({}, { __index = Mailbox })
57
58    function inbox:send_query(query, messages)
59        local result = Set {}
60        local field, value = query:match('^HEADER ([^ ]+) "(.*)"$')
61        if not field then field, value = query:match('^(%u+) "(.*)"$') end
62        for _, message in ipairs(messages or self:_send_query()) do
63            local case = cases[message[2]]
64            local matches = query == 'FLAGGED' and case.flagged
65            if field then
66                local header = case[field:lower()]
67                matches = header and header:lower():find(value:lower(), 1, true)
68            end
69            if matches then result[#result + 1] = message end
70        end
71        return result
72    end
73
74    function inbox:_send_query()
75        local result = Set {}
76        for id in ipairs(cases) do result[#result + 1] = { self, id } end
77        return result
78    end
79
80    function inbox:_fetch_fields(fields, messages)
81        local result = {}
82        for _, id in ipairs(messages) do
83            local field = fields[1]:lower()
84            local value = cases[id][field]
85            if field == 'subject' then value = cases[id].raw_subject or value end
86            if value then result[id] = fields[1] .. ': ' .. value .. '\r\n' end
87        end
88        return result
89    end
90
91    function inbox:move_messages(destination, messages)
92        assert(not dry_run, 'Dry run attempted to move mail')
93        for _, message in ipairs(messages) do
94            local id = message[2]
95            assert(not moved[id], 'Message filed twice: ' .. cases[id].name)
96            moved[id] = destination.folder
97        end
98        return true
99    end
100
101    local account = setmetatable({ INBOX = inbox }, {
102        __index = function(_, folder) return { folder = folder } end,
103    })
104    local env = setmetatable({
105        IMAP = function() return account end,
106        dofile = function() return { { username = 'test', password_env = 'TEST_PASSWORD' } } end,
107        os = { getenv = function(name)
108            if name == 'IMAPFILTER_DRY_RUN' then return dry_run and '1' or nil end
109            if name == 'HOME' then return '/nonexistent' end
110            if name == 'TEST_PASSWORD' then return 'not-a-real-password' end
111        end },
112        print = function(line)
113            local folder, id = line:match('^MATCH\ttest\t([^\t]+)\t(%d+)$')
114            if id then
115                id = tonumber(id)
116                assert(not preview[id], 'Message previewed twice: ' .. cases[id].name)
117                preview[id] = folder
118            end
119        end,
120    }, { __index = _G })
121    assert(loadfile('config.lua', 't', env))()
122    for id, case in ipairs(cases) do
123        local actual = (dry_run and preview or moved)[id]
124        assert(actual == case.folder, case.name .. ': expected ' .. (case.folder or 'INBOX') .. ', got ' .. (actual or 'INBOX'))
125    end
126end
127print(#cases .. ' classification cases passed in live and dry-run modes (no network)')