char/imapfilter-config
git clone https://git.t4t.associates/char/imapfilter-config
6a9e105
main
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 55local moved , preview ={} , {} 56local inbox =setmetatable ( {} , { __index =Mailbox } ) 57 58function inbox : send_query ( query , messages ) 59local result =Set {} 60local field , value =query : match ( '^HEADER ([^ ]+) "(.*)"$' ) 61if not field then field , value =query : match ( '^(%u+) "(.*)"$' ) end 62for _ , message in ipairs ( messages or self : _send_query ()) do 63local case =cases [ message [ 2 ]] 64local matches =query == 'FLAGGED' and case . flagged 65if field then 66local header =case [ field : lower ()] 67matches =header and header : lower (): find ( value : lower (), 1 , true ) 68end 69if matches then result [ # result + 1 ] =message end 70end 71return result 72end 73 74function inbox : _send_query () 75local result =Set {} 76for id in ipairs ( cases ) do result [ # result + 1 ] ={ self , id } end 77return result 78end 79 80function inbox : _fetch_fields ( fields , messages ) 81local result ={} 82for _ , id in ipairs ( messages ) do 83local field =fields [ 1 ]: lower () 84local value =cases [ id ][ field ] 85if field == 'subject' then value =cases [ id ]. raw_subject or value end 86if value then result [ id ] =fields [ 1 ] .. ': ' .. value .. '\r\n' end 87end 88return result 89end 90 91function inbox : move_messages ( destination , messages ) 92assert ( not dry_run , 'Dry run attempted to move mail' ) 93for _ , message in ipairs ( messages ) do 94local id =message [ 2 ] 95assert ( not moved [ id ], 'Message filed twice: ' .. cases [ id ]. name ) 96moved [ id ] =destination . folder 97end 98return true 99end 100 101local account =setmetatable ( { INBOX =inbox } , { 102__index =function ( _ , folder ) return { folder =folder } end , 103} ) 104local env =setmetatable ( { 105IMAP =function () return account end , 106dofile =function () return { { username ='test' , password_env ='TEST_PASSWORD' } } end , 107os ={ getenv =function ( name ) 108if name == 'IMAPFILTER_DRY_RUN' then return dry_run and '1' or nil end 109if name == 'HOME' then return '/nonexistent' end 110if name == 'TEST_PASSWORD' then return 'not-a-real-password' end 111end } , 112function ( line ) 113local folder , id =line : match ( '^MATCH\ttest\t([^\t]+)\t(%d+)$' ) 114if id then 115id =tonumber ( id ) 116assert ( not preview [ id ], 'Message previewed twice: ' .. cases [ id ]. name ) 117preview [ id ] =folder 118end 119end , 120} , { __index =_G } ) 121assert ( loadfile ( 'config.lua' , 't' , env ))() 122for id , case in ipairs ( cases ) do 123local actual =( dry_run and preview or moved )[ id ] 124assert ( actual == case . folder , case . name .. ': expected ' .. ( case . folder or 'INBOX' ) .. ', got ' .. ( actual or 'INBOX' )) 125end 126end 127( # cases .. ' classification cases passed in live and dry-run modes (no network)' )