Compare commits

...

246 commits
0.1.0 ... main

Author SHA1 Message Date
Quentin 0dcf69f180
bump rust toolchain + fix publish script bug 2024-02-24 12:24:51 +01:00
Quentin d92ae5220c Merge pull request 'Perf measurement & bottleneck fix' (#102) from perf/cpu-ram-bottleneck into main
Reviewed-on: #102
2024-02-23 17:32:38 +00:00
Quentin 1ea3de3099
bumping to 0.2.2 2024-02-23 18:32:09 +01:00
Quentin 0b122582e8
fix code formatting 2024-02-23 18:28:04 +01:00
Quentin ab03c7a160
Upgrade Cargo.nix 2024-02-23 18:27:38 +01:00
Quentin 2a084df300
Also share HTTPClient for K2V 2024-02-23 17:31:29 +01:00
Quentin 02a8537556
Replace with a single AWS HTTP client 2024-02-23 17:01:51 +01:00
Quentin a579382042
update flake dependency 2024-02-23 08:46:05 +01:00
Quentin 38a8c7de2a
upgrade cargo2nix 2024-02-22 17:32:18 +01:00
Quentin 9b26e251e3
formatting 2024-02-22 17:31:03 +01:00
Quentin 2adf73dd8e
Update imap-flow, clean IDLE 2024-02-22 17:30:40 +01:00
Quentin 3f204b102a
fix test 2024-02-22 11:51:58 +01:00
Quentin 4d501b6947
Compile streams 2024-02-22 11:35:39 +01:00
Quentin de5717a020
Upgrade Cargo.nix 2024-02-20 16:02:56 +01:00
Quentin 64b474f682
Unsollicited response on APPEND was wrong, upgrade imap-flow to fix LITERAL+ 2024-02-20 13:24:42 +01:00
Quentin 28b1f4f14d
Unsollicited responses on APPEND 2024-02-20 11:42:51 +01:00
Quentin 4aa31ba8b5
Add datasets 2024-02-16 18:55:46 +01:00
Quentin 0b20d726bb
Add a 100 emails dataset on Git LFS 2024-02-15 11:12:20 +01:00
Quentin 0bb7cdf696
Set pipelinable commands to 64 2024-02-15 11:04:10 +01:00
Quentin d50b1dc178 Merge pull request 'Debug the Dovecot Auth Protocol' (#95) from bug/dovecot-auth-resp into main
Reviewed-on: #95
2024-02-13 16:13:53 +00:00
Quentin 9377ca3ef4
Accept authz id == auth id 2024-02-13 16:57:01 +01:00
Quentin 25e716a17f
dovecot plain auth inline continuation support 2024-02-13 11:21:11 +01:00
Quentin e778bebfd3
Fix nix develop 2024-02-13 10:32:11 +01:00
Quentin ede836fc80
automate publishing with nix 2024-02-10 18:04:27 +01:00
Quentin 3dfe914fda
add building scripts 2024-02-10 17:29:32 +01:00
Quentin 9954cea30f
fix cargo.nix 2024-02-10 13:44:02 +01:00
Quentin 3b675ac357 Merge pull request 'WIP 0.2.1' (#93) from bug/deployment into main
Reviewed-on: #93
2024-02-10 11:11:55 +00:00
Quentin 0e3cfe536f
Escape LMTP data 2024-02-10 12:11:01 +01:00
Quentin 599480c3d3
Switch version to 0.2.1 2024-02-08 19:41:40 +01:00
Quentin 59f4bdf9d0
fix idle loop error 2024-02-08 19:40:43 +01:00
Quentin 678c5bacc6
add way more logging 2024-02-08 15:12:52 +01:00
Quentin 22f0eb901a
format + fix storage bug 2024-01-31 11:01:18 +01:00
Quentin c27919a757
upgrade k2v to 0.9.1 2024-01-30 17:34:16 +01:00
Quentin 1d6344363a
retrieve missing attributes ldap 2024-01-30 15:45:48 +01:00
Quentin 93c0aa4b3a
Various post-release fixes 2024-01-25 11:35:33 +01:00
Quentin 414634f597
Update cargo.nix 2024-01-25 10:03:22 +01:00
Quentin 1730bd6c10 Merge pull request 'feat/finalize-v0.2' (#82) from feat/finalize-v0.2 into main
Reviewed-on: #82
2024-01-25 08:13:03 +00:00
Quentin efd9ae5def
Fix postfix bug 2024-01-24 23:09:29 +01:00
Quentin 06d37d3399
correctly parse sasl 2024-01-24 22:15:33 +01:00
Quentin 337b7bce6d
Encoding of server commmands 2024-01-24 22:06:22 +01:00
Quentin b86acd5ed0
implemented business logic 2024-01-24 21:36:46 +01:00
Quentin bbb050e399
Basic response encoding 2024-01-24 18:57:50 +01:00
Quentin 0adb92e8ff
AuthOptions parsing 2024-01-24 18:30:28 +01:00
Quentin c1bab5808b
QoL connection management 2024-01-24 17:50:03 +01:00
Quentin f9d6c1c927
Basic parsing of Dovecot Client Commands 2024-01-24 17:32:47 +01:00
Quentin 9afd2ea337
Dovecot auth types 2024-01-24 15:21:55 +01:00
Quentin 9a265a09e2
WIP Dovecot Authentication Protocol Server 2024-01-23 21:09:57 +01:00
Quentin f67f04129a
Add TLS support 2024-01-23 16:14:58 +01:00
Quentin 1f449dc7e9
Rework some details (env var, cargo desc) 2024-01-22 13:59:58 +01:00
Quentin 4eebc2cb7d
Sync Cargo.nix with Cargo.lock 2024-01-20 19:31:21 +01:00
Quentin 5711787e53
Fix Cargo.toml advertised licence, Aerogramme is EUPL 2024-01-20 19:27:56 +01:00
Quentin 3fd22c6fa3
switch to version 0.2.0 2024-01-20 19:24:49 +01:00
Quentin 49ff733a30 Merge pull request 'Implement LIST X Y RETURN (STATUS (UIDNEXT ...))' (#75) from feat/list-status into main
Reviewed-on: #75
2024-01-20 18:24:05 +00:00
Quentin 9c3f447480
Test LIST-STATUS 2024-01-20 19:23:44 +01:00
Quentin 9ae5701c7c
Implement LIST X Y RETURN (STATUS (UIDNEXT ...)) 2024-01-20 18:34:37 +01:00
Quentin 4849d776b4 Merge pull request 'UIDPLUS' (#73) from uidplus into main
Reviewed-on: #73
2024-01-20 10:45:56 +00:00
Quentin 369c68231f
test UIDPLUS 2024-01-20 11:45:32 +01:00
Quentin a042d9d29e
fix warnings 2024-01-19 17:42:57 +01:00
Quentin f5f3aba8d1
format code 2024-01-19 17:40:08 +01:00
Quentin c2a518a997
filter expunge 2024-01-19 17:39:55 +01:00
Quentin 0cc38571f4
Implement some part of SPECIAL-USE 2024-01-19 16:47:20 +01:00
Quentin 0f227e44e4 Merge pull request 'Implement IDLE' (#72) from feat/idle into main
Reviewed-on: #72
2024-01-19 14:04:03 +00:00
Quentin 23aa313e11
Testing idle 2024-01-19 14:13:43 +01:00
Quentin 2c5adc8f16
reformat code 2024-01-18 18:03:21 +01:00
Quentin 43b668531f
fix a transition bug 2024-01-18 18:02:24 +01:00
Quentin 185033c462
idling works!!! 2024-01-18 17:33:57 +01:00
Quentin e1161cab0e
idle sync 2024-01-17 16:56:05 +01:00
Quentin 4a15ceacf1
Update dependency 2024-01-17 10:28:04 +01:00
Quentin 1a0247e935
WIP idle 2024-01-17 10:14:48 +01:00
Quentin 0eb8156cde
Delete EXAMINE that has been merged in SELECTED 2024-01-17 08:33:08 +01:00
Quentin 3d23f0c936
WIP refactor idle 2024-01-17 08:22:15 +01:00
Quentin 55e26d24a0 Merge pull request 'CONDSTORE' (#71) from feat/condstore-try-2 into main
Reviewed-on: #71
2024-01-15 07:07:06 +00:00
Quentin 81bfed3b7d
testing condstore 2024-01-15 08:06:04 +01:00
Quentin 22cd0764d8
rewrite store testing logic 2024-01-12 15:02:02 +01:00
Quentin c1e7f7264a
fix a condstore bug 2024-01-12 13:01:22 +01:00
Quentin 6963287986
Fix unit tests 2024-01-12 09:54:58 +01:00
Quentin 3c7186ab5a
Finalize implementation of CONDSTORE 2024-01-11 23:02:03 +01:00
Quentin d24eb9918e
Enable CONDSTORE on STORE/FETCH modifier 2024-01-11 17:13:59 +01:00
Quentin 60a166185a
Fetch and store modifiers are parsed 2024-01-11 16:55:37 +01:00
Quentin a9d33c6708
MODSEQ is now returned on non empty search results 2024-01-11 11:55:40 +01:00
Quentin fbf2e9aa96
Enable CONDSTORE if SEARCH MODSEQ is queried 2024-01-11 11:48:02 +01:00
Quentin 917c32ae0b
MODSEQ search key first implementation 2024-01-11 10:10:00 +01:00
Quentin f4cbf66549
Fecth MODSEQ now enables the CONDSTORE capability 2024-01-10 18:38:21 +01:00
Quentin f5b73182f2
Fetch now support MODSEQ data item 2024-01-10 18:08:44 +01:00
Quentin 9cec7803d2
Implement HIGHESTMODSEQ for STATUS 2024-01-10 17:07:07 +01:00
Quentin 96332c9bfe
upgrading imap-flow,codec,types 2024-01-10 15:15:12 +01:00
Quentin 0c6e745d11
update imap-codec 2024-01-10 14:45:36 +01:00
Quentin 20193aa023
Return highestmodseq in select+examine 2024-01-10 13:59:43 +01:00
Quentin 51510c97f7
fix some logic error in the internals 2024-01-10 12:55:38 +01:00
Quentin a2d6efc962
[broken compilation] update mail internal 2024-01-10 11:24:01 +01:00
Quentin 184328ebcf
Optional Parameters with the SELECT/EXAMINE Commands
See: https://datatracker.ietf.org/doc/html/rfc4466#section-2.4
2024-01-09 19:16:55 +01:00
Quentin 6e798b90f5
prepare condstore 2024-01-09 17:40:23 +01:00
Quentin 5dfa02e381
Disable UNSEEN again as it was a volunteer decision to not implement it 2024-01-09 16:53:32 +01:00
Quentin d49a2355f7
Reject \n alone, require \r\n 2024-01-08 22:46:39 +01:00
Quentin 356776cba3 Merge pull request 'bug/thunderbird' (#68) from bug/thunderbird into main
Reviewed-on: #68
2024-01-08 20:34:58 +00:00
Quentin 5cc0a4e512
remove wild log 2024-01-08 21:33:39 +01:00
Quentin 056f8ea14c
Better choose wether or not a body is required 2024-01-08 21:32:55 +01:00
Quentin a90f425d32
Futures must be ordered 2024-01-08 21:18:45 +01:00
Quentin fe28120676
bodystructure final fix 2024-01-08 16:03:42 +01:00
Quentin 07e2e50928
Fetch BODYSTRUCTURE now returns a BODYSTRUCTURE 2024-01-08 15:54:20 +01:00
Quentin 8b5eb25c0c
Status now returns UNSEEN 2024-01-08 15:07:02 +01:00
Quentin 0acbbe66c1
Fix wording in expectations 2024-01-08 14:05:44 +01:00
Quentin 4d1ec33334
Make sure empty mailbox can be fetched/searched
Required by a client (either GMail for Android, Outlook for iPhone, or
Huawei Email)
2024-01-08 14:02:52 +01:00
Quentin b8b9e20ac0
test dovecot is updated 2024-01-08 12:03:14 +01:00
Quentin d7788e29a8 Merge pull request 'Implement search' (#61) from feat/search into main
Reviewed-on: #61
2024-01-08 10:39:26 +00:00
Quentin 42a54b2c50 Merge branch 'main' into feat/search 2024-01-08 10:39:15 +00:00
Quentin 72f9a221ed
Formatting & tests 2024-01-08 11:14:34 +01:00
Quentin 558e32fbd2
UID sequence are now correctly fetched 2024-01-08 11:13:13 +01:00
Quentin 35fd24ee46
Add the ENABLE capability, reduce wild logging 2024-01-08 07:52:45 +01:00
Quentin 152d5b7604
add courier imap 2024-01-07 22:27:12 +01:00
Quentin 1531600fd0
update maddy and cyrus 2024-01-07 21:53:28 +01:00
Quentin 1d84b0ffd0
Format code 2024-01-06 23:35:23 +01:00
Quentin 4e3cbf79d0
implemented text search 2024-01-06 23:24:44 +01:00
Quentin 5622a71cd1
Search MIME headers 2024-01-06 22:53:41 +01:00
Quentin 73fc5e77df
Quickly import lot of emails 2024-01-06 21:42:08 +01:00
Quentin ea1772df42
Searching on storage date is now possible 2024-01-06 20:40:18 +01:00
Quentin 870de493c8
Search is made more clear 2024-01-06 18:51:21 +01:00
Quentin f58904f5bb
Search can now filter on index data 2024-01-06 18:01:44 +01:00
Quentin d495538d55
Stop dumping parsed emails in the logs 2024-01-06 14:45:26 +01:00
Quentin 99a802a7a4
update cargo.nix 2024-01-06 12:53:58 +01:00
Quentin 44ca458c5c Merge pull request 'Aerogramme refactoring' (#57) from feat/more-imap-qol into main
Reviewed-on: #57
2024-01-06 10:38:37 +00:00
Quentin 53dbf82cbc
Format code again 2024-01-06 11:33:56 +01:00
Quentin 1ca6cd5de0
search is re-enabled 2024-01-06 11:33:40 +01:00
Quentin 1b64867ea3
Tests are fixed 2024-01-06 11:14:55 +01:00
Quentin a84ba4d42f
Mailbox View made more readable 2024-01-06 11:07:53 +01:00
Quentin 4806f7ff84
WIP rewrite with a query manager 2024-01-05 18:59:19 +01:00
Quentin adf4d33f22
added some utility structures 2024-01-05 17:46:16 +01:00
Quentin 335750a29a
MOVE command is optimized 2024-01-05 15:36:40 +01:00
Quentin d3c156a087
Select what to fecth for search 2024-01-05 15:26:57 +01:00
Quentin 35591ff060
search first ultra minimal implementation 2024-01-05 12:40:49 +01:00
Quentin ac8fb89d56
reformat cargo 2024-01-05 10:05:30 +01:00
Quentin cd74ae5e63
clean imf view 2024-01-05 10:05:09 +01:00
Quentin 271ec2ef51
mime view should be complete 2024-01-05 10:00:41 +01:00
Quentin 0e7595d65a
message structure msg 2024-01-05 09:45:47 +01:00
Quentin e25576e363
bodyext 2024-01-05 09:26:54 +01:00
Quentin 2a9ae1297b
bcp commit 2024-01-04 20:54:21 +01:00
Quentin b22df840db
WIP refactor of the different views 2024-01-04 17:55:16 +01:00
Quentin bcf6de8341 Merge pull request 'Implement some IMAP extensions' (#50) from feat/more-ext into main
Reviewed-on: #50
2024-01-04 11:11:01 +00:00
Quentin 7ae9966675
test enable 2024-01-04 12:09:16 +01:00
Quentin 3f5d7fa766
remove old tests 2024-01-04 11:53:49 +01:00
Quentin a93967a6f8
create a single behavior test with all files 2024-01-04 11:51:14 +01:00
Quentin a0a7dd0ed6
BDD pattern is clarified 2024-01-04 11:23:26 +01:00
Quentin 3a10fb9faa
advertise literal support 2024-01-03 21:29:36 +01:00
Quentin 8180baae00
format code 2024-01-03 20:53:25 +01:00
Quentin a6a0e1994d
ENABLE is now supported 2024-01-03 20:53:07 +01:00
Quentin 7de1c66d86
Thunderbird is now able to correctly list msg info 2024-01-03 18:25:37 +01:00
Quentin 74686ebb77
append ignore dates instead of failing 2024-01-03 16:52:31 +01:00
Quentin b91c64920d
add test for imap move 2024-01-03 15:21:51 +01:00
Quentin ef257e286a
implement move 2024-01-03 15:00:05 +01:00
Quentin 6d37924399
rework capability 2024-01-03 12:29:19 +01:00
Quentin a059585cb4
add test for the unselect extension 2024-01-03 10:28:10 +01:00
Quentin 9ce8e18fb8
Common module in test created 2024-01-03 09:47:52 +01:00
Quentin 7ebc708aca
unselect implemented rfc3691 2024-01-03 09:21:46 +01:00
Quentin b9a0c1e6ec Merge pull request 'Implement imap-flow' (#34) from refactor/imap-flow into main
Reviewed-on: #34
2024-01-02 22:44:29 +00:00
Quentin c9a33c080d
clean tests 2024-01-02 23:43:58 +01:00
Quentin f480ff0d31
tested append 2024-01-02 23:42:47 +01:00
Quentin 0cc13f891c
migration to imap-flow seems done! 2024-01-02 22:32:02 +01:00
Quentin b66b9f75fe
fixed aerogramme tests 2024-01-02 22:09:45 +01:00
Quentin 0d667a3030
compile with imap-flow 2024-01-02 20:23:33 +01:00
Quentin 9a8d4c651e
commands now use imap-flow 2024-01-02 15:35:23 +01:00
Quentin 07eea38765
ported commands 2024-01-01 19:25:28 +01:00
Quentin e2d77defc8
fixed anonymous + authenticated imap logic 2024-01-01 17:54:48 +01:00
Quentin d2c3b641fe
WIP rewrite 2024-01-01 09:34:13 +01:00
Quentin 6e20778f74
broken build, reworked dependencies 2023-12-30 11:23:10 +01:00
Quentin 3004c69822
check status 2023-12-30 10:35:01 +01:00
Quentin 608dab8e5d
WIP implem status 2023-12-30 09:29:21 +01:00
Quentin 771c4eac79
covering imap commands 2023-12-29 17:16:41 +01:00
Quentin adb1a3b7c1
fix "fetch x rfc822"
close #33
2023-12-29 12:38:42 +01:00
Quentin b49f7e801b
wip testing 2023-12-28 18:18:21 +01:00
Quentin bf9a5c1757
add a basic test 2023-12-28 16:37:38 +01:00
Quentin ccc9b6abb6
add a --dev mode 2023-12-27 18:33:06 +01:00
Quentin 7744625c18
drop old code 2023-12-27 17:37:25 +01:00
Quentin 6ff3c6f71e Add storage behind a trait
Reviewed-on: #32
2023-12-27 16:35:43 +00:00
Quentin ea4cd48bba
fix metadata 2023-12-27 17:34:49 +01:00
Quentin dea6cd0039
debug implementation 2023-12-27 16:38:27 +01:00
Quentin 7ac24ad913
cargo format 2023-12-27 14:58:28 +01:00
Quentin 54c9736a24
implemente garage storage 2023-12-27 14:58:09 +01:00
Quentin 477a784e45
implement poll 2023-12-26 20:02:13 +01:00
Quentin 18bba784ee
insert logic 2023-12-26 18:33:56 +01:00
Quentin 78f2d86fc8
WIP k2v 2023-12-22 21:52:20 +01:00
Quentin 0f7764d9f0
s3 is now implemented 2023-12-22 19:32:07 +01:00
Quentin 1057661da7
implemented blob_fetch 2023-12-21 22:30:17 +01:00
Quentin 012c6ad672
initialize aws sdk with our info 2023-12-21 21:54:36 +01:00
Quentin 4b8b48b485
upgrade argon2, add aws-sdk-s3 2023-12-21 20:23:43 +01:00
Quentin e3b11ad1d8
fix how mem storage is created 2023-12-21 16:38:15 +01:00
Quentin e9aabe8e82
move storage logic into the storage module 2023-12-21 15:36:05 +01:00
Quentin a3a9f87d2c
avoid infinite loop 2023-12-21 09:32:48 +01:00
Quentin 2830e62df9
working in memory storage 2023-12-20 13:55:23 +01:00
Quentin 3a1f68c6bf
better handle non existing keys 2023-12-19 21:41:35 +01:00
Quentin 8bc40fa087
wip in mem storage bug fixes 2023-12-19 19:21:36 +01:00
Quentin c75f2d91ff
implemented an in memory storage 2023-12-19 19:02:22 +01:00
Quentin 3d41f40dc8
Storage trait new implementation 2023-12-18 17:09:44 +01:00
Quentin 684f4de225
new new new storage interface 2023-12-16 11:13:32 +01:00
Quentin 1b5f2eb695
implement the reload feature 2023-12-14 15:36:54 +01:00
Quentin 1f6e64d34e
add support for hot reloading 2023-12-14 13:03:04 +01:00
Quentin 65f4ceae78
add a password hash tool 2023-12-14 11:30:11 +01:00
Quentin 02626865bf
use bail! instead of panic! 2023-12-13 18:06:18 +01:00
Quentin 29561dde41
CLI tools 2023-12-13 18:04:04 +01:00
Quentin 064a1077c8
it compiles again! 2023-12-13 16:09:01 +01:00
Quentin 47e25cd7f7
WIP 2023-12-12 09:17:59 +01:00
Quentin 23f918fd0e
implement account create 2023-12-08 19:06:12 +01:00
Quentin 532c99f3d3
rework static login provider 2023-12-08 18:13:00 +01:00
Quentin cf18eb8afb
now compile again 2023-12-08 15:23:50 +01:00
Quentin 3ddbce4529
WIP refactor 2023-12-06 20:57:25 +01:00
Quentin 2779837a37
WIP config rework 2023-12-04 16:51:27 +01:00
Quentin e2581c0dfb
reworked configuration file 2023-11-24 11:44:42 +01:00
Quentin 0722886efb
it compiles! 2023-11-23 17:19:35 +01:00
Quentin 8cd9801030
various fixes 2023-11-23 15:16:44 +01:00
Quentin 14c7a96c28
extract setup logic 2023-11-23 15:04:47 +01:00
Quentin a7c9d554f6
fix login mod 2023-11-21 15:09:39 +01:00
Quentin 6e8b2cfc9f
rewrite CryptoKeys with Storage abstraction 2023-11-21 09:56:31 +01:00
Quentin bd6c3464e6
remove old storagecredentials 2023-11-21 09:04:54 +01:00
Quentin 36f4050a40
WIP provider config 2023-11-17 18:46:22 +01:00
Quentin 16b38f3197
integrate storage choice in config 2023-11-17 16:42:25 +01:00
Quentin 89cb8d9572
no more error on baiyou 2023-11-17 15:23:05 +01:00
Quentin e92dc35564
fix orphan storage compatibility 2023-11-17 15:02:43 +01:00
Quentin 4a33ac2265
incoming has been fully ported 2023-11-17 12:15:44 +01:00
Quentin 7eb690e49d
introduce an "orphan" enum 2023-11-17 10:46:13 +01:00
Quentin 6da8b815b6
not very clear how we pass data across channel 2023-11-16 18:27:24 +01:00
Quentin 916b27d87e
WIP refactor storage (new timestamp.rs file) 2023-11-15 15:56:43 +01:00
Quentin 652da6efd3
converted incoming mail
Some checks reported errors
Albatros default
2023-11-02 17:25:56 +01:00
Quentin bf67935c54
add rust analyzer to the shell
Some checks reported errors
Albatros default
2023-11-02 16:17:11 +01:00
Quentin a65f5b2589
WIP rewrite mail/incoming
Some checks reported errors
Albatros default
2023-11-02 15:28:19 +01:00
Quentin 1e192f93d5
make all our objects send+sync
Some checks reported errors
Albatros default
2023-11-02 12:58:45 +01:00
Quentin 3b363b2a78
implement equality+cmp for builders based on url
Some checks reported errors
Albatros default
2023-11-02 12:18:43 +01:00
Quentin 553ea25f18
gradually implement our interface
Some checks reported errors
Albatros default
2023-11-02 11:51:03 +01:00
Quentin 1f28832dea
start replacing engine
Some checks reported errors
Albatros default
2023-11-02 10:55:40 +01:00
Quentin 73a6a0c014
example usage of boxed futures
Some checks reported errors
Albatros default
2023-11-02 10:45:41 +01:00
Quentin 9aa58194d4
try dynamic dispatch
Some checks reported errors
Albatros default
2023-11-02 10:38:47 +01:00
Quentin 415f51ac4c
sadly switch to dynamic dispatch
Some checks reported errors
Albatros default
2023-11-02 09:57:58 +01:00
Quentin 26f14df3f4
we are doomed with static types
Some checks reported errors
Albatros default
2023-11-02 09:42:50 +01:00
Quentin cf8b9ac28d
mask implementation to the rest of the code
Some checks reported errors
Albatros default
2023-11-01 17:18:58 +01:00
Quentin 8ac3a8ce8b
implement an AnyCredentials
Some checks reported errors
Albatros default
2023-11-01 16:45:29 +01:00
Quentin 3026b21777
integration to login with an enum
Some checks reported errors
Albatros default
2023-11-01 15:36:06 +01:00
Quentin 92fea414d9
v2 api storage
Some checks reported errors
Albatros default
2023-11-01 15:15:57 +01:00
Quentin c3bb2b62a8
rework interface
Some checks reported errors
Albatros default
2023-11-01 09:25:09 +01:00
Quentin 95685ba9a7
a first naive version of the storage interface
Some checks reported errors
Albatros default
2023-11-01 09:20:36 +01:00
Quentin 0a76db1b8c
WIP traits for the storage
Some checks reported errors
Albatros default
2023-10-30 18:07:40 +01:00
Quentin 609dde4139 Merge pull request 'partial re-implementation of body ext' (#30) from bodyext into main
Reviewed-on: #30
2023-10-12 10:22:41 +00:00
Quentin a1b7ca17c0
basic body ext testing + format 2023-10-12 12:21:59 +02:00
Quentin b444ef7ef3
finally code that build 2023-10-10 17:59:34 +02:00
Quentin f24c06312b
WIP refactor, support LSP 2023-10-09 12:00:16 +02:00
Quentin 2270aaa963
WIP 2023-09-28 11:57:46 +02:00
Quentin 1fb9970502
add back header fields 2023-09-21 11:27:33 +02:00
Quentin b32bb6071a
partial re-implementation of body ext
header fields is still missing
2023-08-16 18:01:22 +02:00
Quentin 726b8c0015
ignore generated files for lang detection
Some checks reported errors
Albatros default
2023-07-26 09:57:51 +02:00
77 changed files with 16712 additions and 5338 deletions

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
Cargo.nix linguist-vendored
flake.lock linguist-vendored

2695
Cargo.lock generated

File diff suppressed because it is too large Load diff

4409
Cargo.nix vendored

File diff suppressed because it is too large Load diff

View file

@ -1,55 +1,82 @@
[package]
name = "aerogramme"
version = "0.1.0"
version = "0.2.2"
authors = ["Alex Auvolat <alex@adnab.me>", "Quentin Dufour <quentin@dufour.io>"]
edition = "2021"
license = "AGPL-3.0"
description = "Encrypted mail storage over Garage"
license = "EUPL-1.2"
description = "A robust email server"
[dependencies]
anyhow = "1.0.28"
argon2 = "0.3"
async-trait = "0.1"
backtrace = "0.3"
base64 = "0.13"
clap = { version = "3.1.18", features = ["derive", "env"] }
duplexify = "1.1.0"
eml-codec = "0.1.1"
hex = "0.4"
futures = "0.3"
im = "15"
itertools = "0.10"
lazy_static = "1.4"
ldap3 = { version = "0.10", default-features = false, features = ["tls-rustls"] }
log = "0.4"
rusoto_core = { version = "0.48.0", default_features = false, features = ["rustls"] }
rusoto_credential = "0.48.0"
rusoto_s3 = { version = "0.48.0", default_features = false, features = ["rustls"] }
hyper-rustls = { version = "0.24", features = ["http2"] }
rusoto_signature = "0.48.0"
serde = "1.0.137"
rand = "0.8.5"
rmp-serde = "0.15"
rpassword = "7.0"
sodiumoxide = "0.2"
# async runtime
tokio = { version = "1.18", default-features = false, features = ["rt", "rt-multi-thread", "io-util", "net", "time", "macros", "sync", "signal", "fs"] }
tokio-util = { version = "0.7", features = [ "compat" ] }
toml = "0.5"
zstd = { version = "0.9", default-features = false }
futures = "0.3"
# debug
log = "0.4"
backtrace = "0.3"
console-subscriber = "0.2"
tracing-subscriber = "0.3"
tracing = "0.1"
tower = "0.4"
imap-codec = { git = "https://github.com/superboum/imap-codec.git", branch = "v0.5.x" }
# language extensions
lazy_static = "1.4"
duplexify = "1.1.0"
im = "15"
anyhow = "1.0.28"
async-trait = "0.1"
itertools = "0.10"
chrono = { version = "0.4", default-features = false, features = ["alloc"] }
k2v-client = { git = "https://git.deuxfleurs.fr/Deuxfleurs/garage.git", tag = "v0.8.2" }
boitalettres = { git = "https://git.deuxfleurs.fr/quentin/boitalettres.git", branch = "expose-mydatetime" }
# process related
nix = { version = "0.27", features = ["signal"] }
clap = { version = "3.1.18", features = ["derive", "env"] }
# serialization & parsing
serde = "1.0.137"
rmp-serde = "0.15"
toml = "0.5"
base64 = "0.21"
hex = "0.4"
nom = "7.1"
zstd = { version = "0.9", default-features = false }
# cryptography & security
sodiumoxide = "0.2"
argon2 = "0.5"
rand = "0.8.5"
rustls = "0.22"
rustls-pemfile = "2.0"
tokio-rustls = "0.25"
hyper-rustls = { version = "0.26", features = ["http2"] }
hyper-util = { version = "0.1", features = ["full"] }
rpassword = "7.0"
# login
ldap3 = { version = "0.10", default-features = false, features = ["tls-rustls"] }
# storage
k2v-client = { git = "https://git.deuxfleurs.fr/Deuxfleurs/garage.git", branch = "k2v/shared_http_client" }
aws-config = { version = "1", features = ["behavior-version-latest"] }
aws-sdk-s3 = "1"
aws-smithy-runtime = "1"
aws-smithy-runtime-api = "1"
# email protocols
eml-codec = "0.1.2"
smtp-message = { git = "http://github.com/Alexis211/kannader", branch = "feature/lmtp" }
smtp-server = { git = "http://github.com/Alexis211/kannader", branch = "feature/lmtp" }
#k2v-client = { path = "../garage/src/k2v-client" }
imap-codec = { version = "2.0.0", features = ["bounded-static", "ext_condstore_qresync"] }
imap-flow = { git = "https://github.com/duesee/imap-flow.git", branch = "main" }
thiserror = "1.0.56"
[dev-dependencies]
[patch.crates-io]
imap-types = { git = "https://github.com/superboum/imap-codec", branch = "custom/aerogramme" }
imap-codec = { git = "https://github.com/superboum/imap-codec", branch = "custom/aerogramme" }
[[test]]
name = "behavior"
path = "tests/behavior.rs"
harness = false

View file

@ -19,10 +19,18 @@ A resilient & standards-compliant open-source IMAP server with built-in encrypti
## Roadmap
- ✅ 0.1 Better emails parsing (july '23, see [eml-codec](https://git.deuxfleurs.fr/Deuxfleurs/eml-codec)).
- ⌛0.2 Support of IMAP4rev1. (~september '23).
- ⌛0.3 Subset of IMAP4rev2. (~december '23).
- ⌛0.4 CalDAV support. (~february '24).
- ⌛0.5 CardDAV support.
- ✅ 0.2 Support of IMAP4. (~january '24).
- ⌛0.3 CalDAV support. (~february '24).
- ⌛0.4 CardDAV support.
- ⌛0.5 Public beta.
## A note about cargo2nix
Currently, you must edit Cargo.nix by hand after running `cargo2nix`.
Find the `tokio` dependency declaration.
Look at tokio's dependencies, the `tracing` is disable through a `if false` logic.
Activate it by replacing the condition with `if true`.
## Sponsors and funding

118
flake.lock vendored
View file

@ -1,10 +1,30 @@
{
"nodes": {
"albatros": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1684830446,
"narHash": "sha256-jyYwYYNKSe40Y9OirIkeFTvTvqNj0NErh4TNBJmujw4=",
"ref": "main",
"rev": "fb80c5d6734044ca7718989a3b36503b9463f1b2",
"revCount": 81,
"type": "git",
"url": "https://git.deuxfleurs.fr/Deuxfleurs/albatros.git"
},
"original": {
"ref": "main",
"type": "git",
"url": "https://git.deuxfleurs.fr/Deuxfleurs/albatros.git"
}
},
"cargo2nix": {
"inputs": {
"flake-compat": "flake-compat",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2",
"rust-overlay": "rust-overlay"
},
"locked": {
@ -24,20 +44,19 @@
},
"fenix": {
"inputs": {
"nixpkgs": "nixpkgs_2",
"nixpkgs": "nixpkgs_3",
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1688484237,
"narHash": "sha256-qFUn2taHGe203wm7Oio4UGFz1sAiq+kitRexY3sQ1CA=",
"lastModified": 1708669354,
"narHash": "sha256-eGhZLjF59aF9bYdSOleT1BD94qvo1NgMio4vMKBzxgY=",
"owner": "nix-community",
"repo": "fenix",
"rev": "626a9e0a84010728b335f14d3982e11b99af7dc6",
"rev": "a0f0f781683e4e93b61beaf1dfee4dd34cf3a092",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "monthly",
"repo": "fenix",
"type": "github"
}
@ -59,6 +78,24 @@
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"locked": {
"lastModified": 1667395993,
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
@ -73,16 +110,16 @@
"type": "github"
}
},
"flake-utils_2": {
"flake-utils_3": {
"inputs": {
"systems": "systems"
"systems": "systems_2"
},
"locked": {
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"lastModified": 1705309234,
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
"type": "github"
},
"original": {
@ -92,6 +129,21 @@
}
},
"nixpkgs": {
"locked": {
"lastModified": 1678964307,
"narHash": "sha256-POV15raLJzwns6U84W4aWNSeSJRXTz7xWQW6IcrWQns=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "fd4f7832961053e6095af8de8d6a57b5ad402f19",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1672580127,
"narHash": "sha256-3lW3xZslREhJogoOkjeZtlBtvFMyxHku7I/9IVehhT8=",
@ -107,13 +159,13 @@
"type": "github"
}
},
"nixpkgs_2": {
"nixpkgs_3": {
"locked": {
"lastModified": 1688231357,
"narHash": "sha256-ZOn16X5jZ6X5ror58gOJAxPfFLAQhZJ6nOUeS4tfFwo=",
"lastModified": 1706550542,
"narHash": "sha256-UcsnCG6wx++23yeER4Hg18CXWbgNpqNXcHIo5/1Y+hc=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "645ff62e09d294a30de823cb568e9c6d68e92606",
"rev": "97b17f32362e475016f942bbdfda4a4a72a8a652",
"type": "github"
},
"original": {
@ -123,13 +175,13 @@
"type": "github"
}
},
"nixpkgs_3": {
"nixpkgs_4": {
"locked": {
"lastModified": 1690294827,
"narHash": "sha256-JV53dEaMM566e+6R4Wj58jBAkFg7HaZr3SsXZ9hdh40=",
"lastModified": 1708673722,
"narHash": "sha256-FPbPhA727wuVkmR21Va6scRjAmj4pk3U8blteaXB/Hg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7ce0abe77d2ace6d6fc43ff7077019e62a77e741",
"rev": "92cf4feb2b9091466a82b27e4bb045cbccc2ba09",
"type": "github"
},
"original": {
@ -141,20 +193,21 @@
},
"root": {
"inputs": {
"albatros": "albatros",
"cargo2nix": "cargo2nix",
"fenix": "fenix",
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_3"
"flake-utils": "flake-utils_3",
"nixpkgs": "nixpkgs_4"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1688410727,
"narHash": "sha256-TqKZO9D64UDBCMY2sUP2ebAKP0oY7S9enrHfZaDiqBQ=",
"lastModified": 1706735270,
"narHash": "sha256-IJk+UitcJsxzMQWm9pa1ZbJBriQ4ginXOlPyVq+Cu40=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "45272efec5fcb8bc46e303d6ced8bd2ba095a667",
"rev": "42cb1a2bd79af321b0cc503d2960b73f34e2f92b",
"type": "github"
},
"original": {
@ -203,6 +256,21 @@
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

122
flake.nix
View file

@ -14,12 +14,14 @@
};
# use rust project builds
fenix.url = "github:nix-community/fenix/monthly";
fenix.url = "github:nix-community/fenix";
# import alba releasing tool
albatros.url = "git+https://git.deuxfleurs.fr/Deuxfleurs/albatros.git?ref=main";
};
outputs = { self, nixpkgs, cargo2nix, flake-utils, fenix }:
flake-utils.lib.eachSystem [
"x86_64-linux"
outputs = { self, nixpkgs, cargo2nix, flake-utils, fenix, albatros }:
let platformArtifacts = flake-utils.lib.eachSystem [
"x86_64-unknown-linux-musl"
"aarch64-unknown-linux-musl"
"armv6l-unknown-linux-musleabihf"
@ -52,18 +54,6 @@
];
};
pkgVanilla = import nixpkgs { system = "x86_64-linux"; };
shell = pkgVanilla.mkShell {
buildInputs = [
cargo2nix.packages.x86_64-linux.default
fenix.packages.x86_64-linux.minimal.toolchain
];
shellHook = ''
echo "AEROGRAME DEVELOPMENT SHELL ${fenix.packages.x86_64-linux.minimal.rustc}"
'';
};
rustTarget = if targetHost == "armv6l-unknown-linux-musleabihf" then "arm-unknown-linux-musleabihf" else targetHost;
# release builds
@ -71,6 +61,8 @@
packageFun = import ./Cargo.nix;
target = rustTarget;
release = true;
#rustcLinkFlags = [ "--cfg" "tokio_unstable" ];
#rustcBuildFlags = [ "--cfg" "tokio_unstable" ];
rustToolchain = with fenix.packages.x86_64-linux; combine [
minimal.cargo
minimal.rustc
@ -120,14 +112,29 @@
];
});
crate = (rustRelease.workspace.aerogramme {});
# binary extract
bin = pkgs.stdenv.mkDerivation {
pname = "aerogramme-bin";
version = "0.1.0";
pname = "${crate.name}-bin";
version = crate.version;
dontUnpack = true;
dontBuild = true;
installPhase = ''
cp ${(rustRelease.workspace.aerogramme {}).bin}/bin/aerogramme $out
cp ${crate.bin}/bin/aerogramme $out
'';
};
# fhs extract
fhs = pkgs.stdenv.mkDerivation {
pname = "${crate.name}-fhs";
version = crate.version;
dontUnpack = true;
dontBuild = true;
installPhase = ''
mkdir -p $out/bin
cp ${crate.bin}/bin/aerogramme $out/bin/
'';
};
@ -146,16 +153,81 @@
container = pkgs.dockerTools.buildImage {
name = "dxflrs/aerogramme";
architecture = (builtins.getAttr targetHost archMap).GOARCH;
copyToRoot = fhs;
config = {
Cmd = [ "${bin}" "server" ];
Env = [ "PATH=/bin" ];
Cmd = [ "aerogramme" "--dev" "provider" "daemon" ];
};
};
in {
devShells.default = shell;
packages.debug = (rustDebug.workspace.aerogramme {}).bin;
packages.aerogramme = bin;
packages.container = container;
packages.default = self.packages.${targetHost}.aerogramme;
meta = {
version = crate.version;
};
packages = {
inherit fhs container;
debug = (rustDebug.workspace.aerogramme {}).bin;
aerogramme = bin;
default = self.packages.${targetHost}.aerogramme;
};
});
###
#
# RELEASE STUFF
#
###
gpkgs = import nixpkgs {
system = "x86_64-linux"; # hardcoded as we will cross compile
};
alba = albatros.packages.x86_64-linux.alba;
# Shell
shell = gpkgs.mkShell {
buildInputs = [
cargo2nix.packages.x86_64-linux.default
fenix.packages.x86_64-linux.minimal.toolchain
fenix.packages.x86_64-linux.rust-analyzer
];
shellHook = ''
echo "AEROGRAME DEVELOPMENT SHELL ${fenix.packages.x86_64-linux.minimal.rustc}"
export RUST_SRC_PATH="${fenix.packages.x86_64-linux.latest.rust-src}/lib/rustlib/src/rust/library"
export RUST_ANALYZER_INTERNALS_DO_NOT_USE='this is unstable'
'';
};
# Used only to fetch the "version"
version = platformArtifacts.meta.x86_64-unknown-linux-musl.version;
build = gpkgs.writeScriptBin "aerogramme-build" ''
set -euxo pipefail
# static
nix build --print-build-logs .#packages.x86_64-unknown-linux-musl.aerogramme -o static/linux/amd64/aerogramme
nix build --print-build-logs .#packages.aarch64-unknown-linux-musl.aerogramme -o static/linux/arm64/aerogramme
nix build --print-build-logs .#packages.armv6l-unknown-linux-musleabihf.aerogramme -o static/linux/arm/aerogramme
# containers
nix build --print-build-logs .#packages.x86_64-unknown-linux-musl.container -o docker/linux.amd64.tar.gz
nix build --print-build-logs .#packages.aarch64-unknown-linux-musl.container -o docker/linux.arm64.tar.gz
nix build --print-build-logs .#packages.armv6l-unknown-linux-musleabihf.container -o docker/linux.arm.tar.gz
'';
push = gpkgs.writeScriptBin "aerogramme-publish" ''
set -euxo pipefail
${alba} static push -t aerogramme:${version} static/ 's3://download.deuxfleurs.org?endpoint=garage.deuxfleurs.fr&s3ForcePathStyle=true&region=garage' 1>&2
${alba} container push -t aerogramme:${version} docker/ 's3://registry.deuxfleurs.org?endpoint=garage.deuxfleurs.fr&s3ForcePathStyle=true&region=garage' 1>&2
${alba} container push -t aerogramme:${version} docker/ "docker://docker.io/dxflrs/aerogramme:${version}" 1>&2
'';
in
{
devShells.x86_64-linux.default = shell;
packages = {
x86_64-linux = {
inherit build push;
};
} // platformArtifacts.packages;
};
}

View file

@ -1,13 +0,0 @@
s3_endpoint = "http://[::1]:3900"
k2v_endpoint = "http://[::1]:3904"
aws_region = "garage"
[imap]
bind_addr = "[::1]:4567"
[login_static.users.quentin]
password = "$argon2id$v=19$m=4096,t=3,p=1$jR52Nq76f8yO0UXdhK+FiQ$KeIzDI4PJ/2bX+expyyaRkMZus0/1FsgTXtnvPUjwyw"
aws_access_key_id = "GK68198c3b4148f61dcd625b7e"
aws_secret_access_key = "1d4bd3853a4f7810b97cbb2f8eb52c7603eb93c202fe98ca40f4e3f6b7e70fa0"
user_secret = "poupou"
bucket = "quentin-mailrage"

941
src/auth.rs Normal file
View file

@ -0,0 +1,941 @@
use std::net::SocketAddr;
use anyhow::{anyhow, bail, Result};
use futures::stream::{FuturesUnordered, StreamExt};
use tokio::io::BufStream;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::watch;
use crate::config::AuthConfig;
use crate::login::ArcLoginProvider;
/// Seek compatibility with the Dovecot Authentication Protocol
///
/// ## Trace
///
/// ```text
/// S: VERSION 1 2
/// S: MECH PLAIN plaintext
/// S: MECH LOGIN plaintext
/// S: SPID 15
/// S: CUID 17654
/// S: COOKIE f56692bee41f471ed01bd83520025305
/// S: DONE
/// C: VERSION 1 2
/// C: CPID 1
///
/// C: AUTH 2 PLAIN service=smtp
/// S: CONT 2
/// C: CONT 2 base64stringFollowingRFC4616==
/// S: OK 2 user=alice@example.tld
///
/// C: AUTH 42 LOGIN service=smtp
/// S: CONT 42 VXNlcm5hbWU6
/// C: CONT 42 b64User
/// S: CONT 42 UGFzc3dvcmQ6
/// C: CONT 42 b64Pass
/// S: FAIL 42 user=alice
/// ```
///
/// ## RFC References
///
/// PLAIN SASL - https://datatracker.ietf.org/doc/html/rfc4616
///
///
/// ## Dovecot References
///
/// https://doc.dovecot.org/developer_manual/design/auth_protocol/
/// https://doc.dovecot.org/configuration_manual/authentication/authentication_mechanisms/#authentication-authentication-mechanisms
/// https://doc.dovecot.org/configuration_manual/howto/simple_virtual_install/#simple-virtual-install-smtp-auth
/// https://doc.dovecot.org/configuration_manual/howto/postfix_and_dovecot_sasl/#howto-postfix-and-dovecot-sasl
pub struct AuthServer {
login_provider: ArcLoginProvider,
bind_addr: SocketAddr,
}
impl AuthServer {
pub fn new(config: AuthConfig, login_provider: ArcLoginProvider) -> Self {
Self {
bind_addr: config.bind_addr,
login_provider,
}
}
pub async fn run(self: Self, mut must_exit: watch::Receiver<bool>) -> Result<()> {
let tcp = TcpListener::bind(self.bind_addr).await?;
tracing::info!(
"SASL Authentication Protocol listening on {:#}",
self.bind_addr
);
let mut connections = FuturesUnordered::new();
while !*must_exit.borrow() {
let wait_conn_finished = async {
if connections.is_empty() {
futures::future::pending().await
} else {
connections.next().await
}
};
let (socket, remote_addr) = tokio::select! {
a = tcp.accept() => a?,
_ = wait_conn_finished => continue,
_ = must_exit.changed() => continue,
};
tracing::info!("AUTH: accepted connection from {}", remote_addr);
let conn = tokio::spawn(
NetLoop::new(socket, self.login_provider.clone(), must_exit.clone()).run_error(),
);
connections.push(conn);
}
drop(tcp);
tracing::info!("AUTH server shutting down, draining remaining connections...");
while connections.next().await.is_some() {}
Ok(())
}
}
struct NetLoop {
login: ArcLoginProvider,
stream: BufStream<TcpStream>,
stop: watch::Receiver<bool>,
state: State,
read_buf: Vec<u8>,
write_buf: BytesMut,
}
impl NetLoop {
fn new(stream: TcpStream, login: ArcLoginProvider, stop: watch::Receiver<bool>) -> Self {
Self {
login,
stream: BufStream::new(stream),
state: State::Init,
stop,
read_buf: Vec::new(),
write_buf: BytesMut::new(),
}
}
async fn run_error(self) {
match self.run().await {
Ok(()) => tracing::info!("Auth session succeeded"),
Err(e) => tracing::error!(err=?e, "Auth session failed"),
}
}
async fn run(mut self) -> Result<()> {
loop {
tokio::select! {
read_res = self.stream.read_until(b'\n', &mut self.read_buf) => {
// Detect EOF / socket close
let bread = read_res?;
if bread == 0 {
tracing::info!("Reading buffer empty, connection has been closed. Exiting AUTH session.");
return Ok(())
}
// Parse command
let (_, cmd) = client_command(&self.read_buf).map_err(|_| anyhow!("Unable to parse command"))?;
tracing::trace!(cmd=?cmd, "Received command");
// Make some progress in our local state
self.state.progress(cmd, &self.login).await;
if matches!(self.state, State::Error) {
bail!("Internal state is in error, previous logs explain what went wrong");
}
// Build response
let srv_cmds = self.state.response();
srv_cmds.iter().try_for_each(|r| {
tracing::trace!(cmd=?r, "Sent command");
r.encode(&mut self.write_buf)
})?;
// Send responses if at least one command response has been generated
if !srv_cmds.is_empty() {
self.stream.write_all(&self.write_buf).await?;
self.stream.flush().await?;
}
// Reset buffers
self.read_buf.clear();
self.write_buf.clear();
},
_ = self.stop.changed() => {
tracing::debug!("Server is stopping, quitting this runner");
return Ok(())
}
}
}
}
}
// -----------------------------------------------------------------
//
// BUSINESS LOGIC
//
// -----------------------------------------------------------------
use rand::prelude::*;
#[derive(Debug)]
enum AuthRes {
Success(String),
Failed(Option<String>, Option<FailCode>),
}
#[derive(Debug)]
enum State {
Error,
Init,
HandshakePart(Version),
HandshakeDone,
AuthPlainProgress { id: u64 },
AuthDone { id: u64, res: AuthRes },
}
const SERVER_MAJOR: u64 = 1;
const SERVER_MINOR: u64 = 2;
const EMPTY_AUTHZ: &[u8] = &[];
impl State {
async fn try_auth_plain<'a>(&self, data: &'a [u8], login: &ArcLoginProvider) -> AuthRes {
// Check that we can extract user's login+pass
let (ubin, pbin) = match auth_plain(&data) {
Ok(([], (authz, user, pass))) if authz == user || authz == EMPTY_AUTHZ => (user, pass),
Ok(_) => {
tracing::error!("Impersonating user is not supported");
return AuthRes::Failed(None, None);
}
Err(e) => {
tracing::error!(err=?e, "Could not parse the SASL PLAIN data chunk");
return AuthRes::Failed(None, None);
}
};
// Try to convert it to UTF-8
let (user, password) = match (std::str::from_utf8(ubin), std::str::from_utf8(pbin)) {
(Ok(u), Ok(p)) => (u, p),
_ => {
tracing::error!("Username or password contain invalid UTF-8 characters");
return AuthRes::Failed(None, None);
}
};
// Try to connect user
match login.login(user, password).await {
Ok(_) => AuthRes::Success(user.to_string()),
Err(e) => {
tracing::warn!(err=?e, "login failed");
AuthRes::Failed(Some(user.to_string()), None)
}
}
}
async fn progress(&mut self, cmd: ClientCommand, login: &ArcLoginProvider) {
let new_state = 'state: {
match (std::mem::replace(self, State::Error), cmd) {
(Self::Init, ClientCommand::Version(v)) => Self::HandshakePart(v),
(Self::HandshakePart(version), ClientCommand::Cpid(_cpid)) => {
if version.major != SERVER_MAJOR {
tracing::error!(
client_major = version.major,
server_major = SERVER_MAJOR,
"Unsupported client major version"
);
break 'state Self::Error;
}
Self::HandshakeDone
}
(
Self::HandshakeDone { .. },
ClientCommand::Auth {
id, mech, options, ..
},
)
| (
Self::AuthDone { .. },
ClientCommand::Auth {
id, mech, options, ..
},
) => {
if mech != Mechanism::Plain {
tracing::error!(mechanism=?mech, "Unsupported Authentication Mechanism");
break 'state Self::AuthDone {
id,
res: AuthRes::Failed(None, None),
};
}
match options.last() {
Some(AuthOption::Resp(data)) => Self::AuthDone {
id,
res: self.try_auth_plain(&data, login).await,
},
_ => Self::AuthPlainProgress { id },
}
}
(Self::AuthPlainProgress { id }, ClientCommand::Cont { id: cid, data }) => {
// Check that ID matches
if cid != id {
tracing::error!(
auth_id = id,
cont_id = cid,
"CONT id does not match AUTH id"
);
break 'state Self::AuthDone {
id,
res: AuthRes::Failed(None, None),
};
}
Self::AuthDone {
id,
res: self.try_auth_plain(&data, login).await,
}
}
_ => {
tracing::error!("This command is not valid in this context");
Self::Error
}
}
};
tracing::debug!(state=?new_state, "Made progress");
*self = new_state;
}
fn response(&self) -> Vec<ServerCommand> {
let mut srv_cmd: Vec<ServerCommand> = Vec::new();
match self {
Self::HandshakeDone { .. } => {
srv_cmd.push(ServerCommand::Version(Version {
major: SERVER_MAJOR,
minor: SERVER_MINOR,
}));
srv_cmd.push(ServerCommand::Mech {
kind: Mechanism::Plain,
parameters: vec![MechanismParameters::PlainText],
});
srv_cmd.push(ServerCommand::Spid(15u64));
srv_cmd.push(ServerCommand::Cuid(19350u64));
let mut cookie = [0u8; 16];
thread_rng().fill(&mut cookie);
srv_cmd.push(ServerCommand::Cookie(cookie));
srv_cmd.push(ServerCommand::Done);
}
Self::AuthPlainProgress { id } => {
srv_cmd.push(ServerCommand::Cont {
id: *id,
data: None,
});
}
Self::AuthDone {
id,
res: AuthRes::Success(user),
} => {
srv_cmd.push(ServerCommand::Ok {
id: *id,
user_id: Some(user.to_string()),
extra_parameters: vec![],
});
}
Self::AuthDone {
id,
res: AuthRes::Failed(maybe_user, maybe_failcode),
} => {
srv_cmd.push(ServerCommand::Fail {
id: *id,
user_id: maybe_user.clone(),
code: maybe_failcode.clone(),
extra_parameters: vec![],
});
}
_ => (),
};
srv_cmd
}
}
// -----------------------------------------------------------------
//
// DOVECOT AUTH TYPES
//
// -----------------------------------------------------------------
#[derive(Debug, Clone, PartialEq)]
enum Mechanism {
Plain,
Login,
}
#[derive(Clone, Debug)]
enum AuthOption {
/// Unique session ID. Mainly used for logging.
Session(u64),
/// Local IP connected to by the client. In standard string format, e.g. 127.0.0.1 or ::1.
LocalIp(String),
/// Remote client IP
RemoteIp(String),
/// Local port connected to by the client.
LocalPort(u16),
/// Remote client port
RemotePort(u16),
/// When Dovecot proxy is used, the real_rip/real_port are the proxys IP/port and real_lip/real_lport are the backends IP/port where the proxy was connected to.
RealRemoteIp(String),
RealLocalIp(String),
RealLocalPort(u16),
RealRemotePort(u16),
/// TLS SNI name
LocalName(String),
/// Enable debugging for this lookup.
Debug,
/// List of fields that will become available via %{forward_*} variables. The list is double-tab-escaped, like: tab_escaped[tab_escaped(key=value)[<TAB>...]
/// Note: we do not unescape the tabulation, and thus we don't parse the data
ForwardViews(Vec<u8>),
/// Remote user has secured transport to auth client (e.g. localhost, SSL, TLS).
Secured(Option<String>),
/// The value can be “insecure”, “trusted” or “TLS”.
Transport(String),
/// TLS cipher being used.
TlsCipher(String),
/// The number of bits in the TLS cipher.
/// @FIXME: I don't know how if it's a string or an integer
TlsCipherBits(String),
/// TLS perfect forward secrecy algorithm (e.g. DH, ECDH)
TlsPfs(String),
/// TLS protocol name (e.g. SSLv3, TLSv1.2)
TlsProtocol(String),
/// Remote user has presented a valid SSL certificate.
ValidClientCert(String),
/// Ignore auth penalty tracking for this request
NoPenalty,
/// Unknown option sent by Postfix
NoLogin,
/// Username taken from clients SSL certificate.
CertUsername,
/// IMAP ID string
ClientId,
/// An unknown key
UnknownPair(String, Vec<u8>),
UnknownBool(Vec<u8>),
/// Initial response for authentication mechanism.
/// NOTE: This must be the last parameter. Everything after it is ignored.
/// This is to avoid accidental security holes if user-given data is directly put to base64 string without filtering out tabs.
/// @FIXME: I don't understand this parameter
Resp(Vec<u8>),
}
#[derive(Debug, Clone)]
struct Version {
major: u64,
minor: u64,
}
#[derive(Debug)]
enum ClientCommand {
/// Both client and server should check that they support the same major version number. If they dont, the other side isnt expected to be talking the same protocol and should be disconnected. Minor version can be ignored. This document specifies the version number 1.2.
Version(Version),
/// CPID finishes the handshake from client.
Cpid(u64),
Auth {
/// ID is a connection-specific unique request identifier. It must be a 32bit number, so typically youd just increment it by one.
id: u64,
/// A SASL mechanism (eg. LOGIN, PLAIN, etc.)
/// See: https://doc.dovecot.org/configuration_manual/authentication/authentication_mechanisms/#authentication-authentication-mechanisms
mech: Mechanism,
/// Service is the service requesting authentication, eg. pop3, imap, smtp.
service: String,
/// All the optional parameters
options: Vec<AuthOption>,
},
Cont {
/// The <id> must match the <id> of the AUTH command.
id: u64,
/// Data that will be serialized to / deserialized from base64
data: Vec<u8>,
},
}
#[derive(Debug)]
enum MechanismParameters {
/// Anonymous authentication
Anonymous,
/// Transfers plaintext passwords
PlainText,
/// Subject to passive (dictionary) attack
Dictionary,
/// Subject to active (non-dictionary) attack
Active,
/// Provides forward secrecy between sessions
ForwardSecrecy,
/// Provides mutual authentication
MutualAuth,
/// Dont advertise this as available SASL mechanism (eg. APOP)
Private,
}
#[derive(Debug, Clone)]
enum FailCode {
/// This is a temporary internal failure, e.g. connection was lost to SQL database.
TempFail,
/// Authentication succeeded, but authorization failed (master users password was ok, but destination user was not ok).
AuthzFail,
/// User is disabled (password may or may not have been correct)
UserDisabled,
/// Users password has expired.
PassExpired,
}
#[derive(Debug)]
enum ServerCommand {
/// Both client and server should check that they support the same major version number. If they dont, the other side isnt expected to be talking the same protocol and should be disconnected. Minor version can be ignored. This document specifies the version number 1.2.
Version(Version),
/// CPID and SPID specify client and server Process Identifiers (PIDs). They should be unique identifiers for the specific process. UNIX process IDs are good choices.
/// SPID can be used by authentication client to tell master which server process handled the authentication.
Spid(u64),
/// CUID is a server process-specific unique connection identifier. Its different each time a connection is established for the server.
/// CUID is currently useful only for APOP authentication.
Cuid(u64),
Mech {
kind: Mechanism,
parameters: Vec<MechanismParameters>,
},
/// COOKIE returns connection-specific 128 bit cookie in hex. It must be given to REQUEST command. (Protocol v1.1+ / Dovecot v2.0+)
Cookie([u8; 16]),
/// DONE finishes the handshake from server.
Done,
Fail {
id: u64,
user_id: Option<String>,
code: Option<FailCode>,
extra_parameters: Vec<Vec<u8>>,
},
Cont {
id: u64,
data: Option<Vec<u8>>,
},
/// FAIL and OK may contain multiple unspecified parameters which authentication client may handle specially.
/// The only one specified here is user=<userid> parameter, which should always be sent if the userid is known.
Ok {
id: u64,
user_id: Option<String>,
extra_parameters: Vec<Vec<u8>>,
},
}
// -----------------------------------------------------------------
//
// DOVECOT AUTH DECODING
//
// ------------------------------------------------------------------
use base64::Engine;
use nom::{
branch::alt,
bytes::complete::{is_not, tag, tag_no_case, take, take_while, take_while1},
character::complete::{tab, u16, u64},
combinator::{map, opt, recognize, rest, value},
error::{Error, ErrorKind},
multi::{many1, separated_list0},
sequence::{pair, preceded, tuple},
IResult,
};
fn version_command<'a>(input: &'a [u8]) -> IResult<&'a [u8], ClientCommand> {
let mut parser = tuple((tag_no_case(b"VERSION"), tab, u64, tab, u64));
let (input, (_, _, major, _, minor)) = parser(input)?;
Ok((input, ClientCommand::Version(Version { major, minor })))
}
fn cpid_command<'a>(input: &'a [u8]) -> IResult<&'a [u8], ClientCommand> {
preceded(
pair(tag_no_case(b"CPID"), tab),
map(u64, |v| ClientCommand::Cpid(v)),
)(input)
}
fn mechanism<'a>(input: &'a [u8]) -> IResult<&'a [u8], Mechanism> {
alt((
value(Mechanism::Plain, tag_no_case(b"PLAIN")),
value(Mechanism::Login, tag_no_case(b"LOGIN")),
))(input)
}
fn is_not_tab_or_esc_or_lf(c: u8) -> bool {
c != 0x09 && c != 0x01 && c != 0x0a // TAB or 0x01 or LF
}
fn is_esc<'a>(input: &'a [u8]) -> IResult<&'a [u8], &[u8]> {
preceded(tag(&[0x01]), take(1usize))(input)
}
fn parameter<'a>(input: &'a [u8]) -> IResult<&'a [u8], &[u8]> {
recognize(many1(alt((take_while1(is_not_tab_or_esc_or_lf), is_esc))))(input)
}
fn parameter_str(input: &[u8]) -> IResult<&[u8], String> {
let (input, buf) = parameter(input)?;
std::str::from_utf8(buf)
.map(|v| (input, v.to_string()))
.map_err(|_| nom::Err::Failure(Error::new(input, ErrorKind::TakeWhile1)))
}
fn is_param_name_char(c: u8) -> bool {
is_not_tab_or_esc_or_lf(c) && c != 0x3d // =
}
fn parameter_name(input: &[u8]) -> IResult<&[u8], String> {
let (input, buf) = take_while1(is_param_name_char)(input)?;
std::str::from_utf8(buf)
.map(|v| (input, v.to_string()))
.map_err(|_| nom::Err::Failure(Error::new(input, ErrorKind::TakeWhile1)))
}
fn service<'a>(input: &'a [u8]) -> IResult<&'a [u8], String> {
preceded(tag_no_case("service="), parameter_str)(input)
}
fn auth_option<'a>(input: &'a [u8]) -> IResult<&'a [u8], AuthOption> {
use AuthOption::*;
alt((
alt((
value(Debug, tag_no_case(b"debug")),
value(NoPenalty, tag_no_case(b"no-penalty")),
value(ClientId, tag_no_case(b"client_id")),
value(NoLogin, tag_no_case(b"nologin")),
map(preceded(tag_no_case(b"session="), u64), |id| Session(id)),
map(preceded(tag_no_case(b"lip="), parameter_str), |ip| {
LocalIp(ip)
}),
map(preceded(tag_no_case(b"rip="), parameter_str), |ip| {
RemoteIp(ip)
}),
map(preceded(tag_no_case(b"lport="), u16), |port| {
LocalPort(port)
}),
map(preceded(tag_no_case(b"rport="), u16), |port| {
RemotePort(port)
}),
map(preceded(tag_no_case(b"real_rip="), parameter_str), |ip| {
RealRemoteIp(ip)
}),
map(preceded(tag_no_case(b"real_lip="), parameter_str), |ip| {
RealLocalIp(ip)
}),
map(preceded(tag_no_case(b"real_lport="), u16), |port| {
RealLocalPort(port)
}),
map(preceded(tag_no_case(b"real_rport="), u16), |port| {
RealRemotePort(port)
}),
)),
alt((
map(
preceded(tag_no_case(b"local_name="), parameter_str),
|name| LocalName(name),
),
map(
preceded(tag_no_case(b"forward_views="), parameter),
|views| ForwardViews(views.into()),
),
map(preceded(tag_no_case(b"secured="), parameter_str), |info| {
Secured(Some(info))
}),
value(Secured(None), tag_no_case(b"secured")),
value(CertUsername, tag_no_case(b"cert_username")),
map(preceded(tag_no_case(b"transport="), parameter_str), |ts| {
Transport(ts)
}),
map(
preceded(tag_no_case(b"tls_cipher="), parameter_str),
|cipher| TlsCipher(cipher),
),
map(
preceded(tag_no_case(b"tls_cipher_bits="), parameter_str),
|bits| TlsCipherBits(bits),
),
map(preceded(tag_no_case(b"tls_pfs="), parameter_str), |pfs| {
TlsPfs(pfs)
}),
map(
preceded(tag_no_case(b"tls_protocol="), parameter_str),
|proto| TlsProtocol(proto),
),
map(
preceded(tag_no_case(b"valid-client-cert="), parameter_str),
|cert| ValidClientCert(cert),
),
)),
alt((
map(preceded(tag_no_case(b"resp="), base64), |data| Resp(data)),
map(
tuple((parameter_name, tag(b"="), parameter)),
|(n, _, v)| UnknownPair(n, v.into()),
),
map(parameter, |v| UnknownBool(v.into())),
)),
))(input)
}
fn auth_command<'a>(input: &'a [u8]) -> IResult<&'a [u8], ClientCommand> {
let mut parser = tuple((
tag_no_case(b"AUTH"),
tab,
u64,
tab,
mechanism,
tab,
service,
map(opt(preceded(tab, separated_list0(tab, auth_option))), |o| {
o.unwrap_or(vec![])
}),
));
let (input, (_, _, id, _, mech, _, service, options)) = parser(input)?;
Ok((
input,
ClientCommand::Auth {
id,
mech,
service,
options,
},
))
}
fn is_base64_core(c: u8) -> bool {
c >= 0x30 && c <= 0x39 // 0-9
|| c >= 0x41 && c <= 0x5a // A-Z
|| c >= 0x61 && c <= 0x7a // a-z
|| c == 0x2b // +
|| c == 0x2f // /
}
fn is_base64_pad(c: u8) -> bool {
c == 0x3d // =
}
fn base64(input: &[u8]) -> IResult<&[u8], Vec<u8>> {
let (input, (b64, _)) = tuple((take_while1(is_base64_core), take_while(is_base64_pad)))(input)?;
let data = base64::engine::general_purpose::STANDARD_NO_PAD
.decode(b64)
.map_err(|_| nom::Err::Failure(Error::new(input, ErrorKind::TakeWhile1)))?;
Ok((input, data))
}
/// @FIXME Dovecot does not say if base64 content must be padded or not
fn cont_command<'a>(input: &'a [u8]) -> IResult<&'a [u8], ClientCommand> {
let mut parser = tuple((tag_no_case(b"CONT"), tab, u64, tab, base64));
let (input, (_, _, id, _, data)) = parser(input)?;
Ok((input, ClientCommand::Cont { id, data }))
}
fn client_command<'a>(input: &'a [u8]) -> IResult<&'a [u8], ClientCommand> {
alt((version_command, cpid_command, auth_command, cont_command))(input)
}
/*
fn server_command(buf: &u8) -> IResult<&u8, ServerCommand> {
unimplemented!();
}
*/
// -----------------------------------------------------------------
//
// SASL DECODING
//
// -----------------------------------------------------------------
fn not_null(c: u8) -> bool {
c != 0x0
}
// impersonated user, login, password
fn auth_plain<'a>(input: &'a [u8]) -> IResult<&'a [u8], (&'a [u8], &'a [u8], &'a [u8])> {
map(
tuple((
take_while(not_null),
take(1usize),
take_while(not_null),
take(1usize),
rest,
)),
|(imp, _, user, _, pass)| (imp, user, pass),
)(input)
}
// -----------------------------------------------------------------
//
// DOVECOT AUTH ENCODING
//
// ------------------------------------------------------------------
use tokio_util::bytes::{BufMut, BytesMut};
trait Encode {
fn encode(&self, out: &mut BytesMut) -> Result<()>;
}
fn tab_enc(out: &mut BytesMut) {
out.put(&[0x09][..])
}
fn lf_enc(out: &mut BytesMut) {
out.put(&[0x0A][..])
}
impl Encode for Mechanism {
fn encode(&self, out: &mut BytesMut) -> Result<()> {
match self {
Self::Plain => out.put(&b"PLAIN"[..]),
Self::Login => out.put(&b"LOGIN"[..]),
}
Ok(())
}
}
impl Encode for MechanismParameters {
fn encode(&self, out: &mut BytesMut) -> Result<()> {
match self {
Self::Anonymous => out.put(&b"anonymous"[..]),
Self::PlainText => out.put(&b"plaintext"[..]),
Self::Dictionary => out.put(&b"dictionary"[..]),
Self::Active => out.put(&b"active"[..]),
Self::ForwardSecrecy => out.put(&b"forward-secrecy"[..]),
Self::MutualAuth => out.put(&b"mutual-auth"[..]),
Self::Private => out.put(&b"private"[..]),
}
Ok(())
}
}
impl Encode for FailCode {
fn encode(&self, out: &mut BytesMut) -> Result<()> {
match self {
Self::TempFail => out.put(&b"temp_fail"[..]),
Self::AuthzFail => out.put(&b"authz_fail"[..]),
Self::UserDisabled => out.put(&b"user_disabled"[..]),
Self::PassExpired => out.put(&b"pass_expired"[..]),
};
Ok(())
}
}
impl Encode for ServerCommand {
fn encode(&self, out: &mut BytesMut) -> Result<()> {
match self {
Self::Version(Version { major, minor }) => {
out.put(&b"VERSION"[..]);
tab_enc(out);
out.put(major.to_string().as_bytes());
tab_enc(out);
out.put(minor.to_string().as_bytes());
lf_enc(out);
}
Self::Spid(pid) => {
out.put(&b"SPID"[..]);
tab_enc(out);
out.put(pid.to_string().as_bytes());
lf_enc(out);
}
Self::Cuid(pid) => {
out.put(&b"CUID"[..]);
tab_enc(out);
out.put(pid.to_string().as_bytes());
lf_enc(out);
}
Self::Cookie(cval) => {
out.put(&b"COOKIE"[..]);
tab_enc(out);
out.put(hex::encode(cval).as_bytes());
lf_enc(out);
}
Self::Mech { kind, parameters } => {
out.put(&b"MECH"[..]);
tab_enc(out);
kind.encode(out)?;
for p in parameters.iter() {
tab_enc(out);
p.encode(out)?;
}
lf_enc(out);
}
Self::Done => {
out.put(&b"DONE"[..]);
lf_enc(out);
}
Self::Cont { id, data } => {
out.put(&b"CONT"[..]);
tab_enc(out);
out.put(id.to_string().as_bytes());
tab_enc(out);
if let Some(rdata) = data {
let b64 = base64::engine::general_purpose::STANDARD.encode(rdata);
out.put(b64.as_bytes());
}
lf_enc(out);
}
Self::Ok {
id,
user_id,
extra_parameters,
} => {
out.put(&b"OK"[..]);
tab_enc(out);
out.put(id.to_string().as_bytes());
if let Some(user) = user_id {
tab_enc(out);
out.put(&b"user="[..]);
out.put(user.as_bytes());
}
for p in extra_parameters.iter() {
tab_enc(out);
out.put(&p[..]);
}
lf_enc(out);
}
Self::Fail {
id,
user_id,
code,
extra_parameters,
} => {
out.put(&b"FAIL"[..]);
tab_enc(out);
out.put(id.to_string().as_bytes());
if let Some(user) = user_id {
tab_enc(out);
out.put(&b"user="[..]);
out.put(user.as_bytes());
}
if let Some(code_val) = code {
tab_enc(out);
out.put(&b"code="[..]);
code_val.encode(out)?;
}
for p in extra_parameters.iter() {
tab_enc(out);
out.put(&p[..]);
}
lf_enc(out);
}
}
Ok(())
}
}

View file

@ -1,23 +1,16 @@
use std::str::FromStr;
use std::sync::{Arc, Weak};
use std::time::{Duration, Instant};
use anyhow::{anyhow, bail, Result};
use log::{debug, error, info};
use log::error;
use rand::prelude::*;
use serde::{Deserialize, Serialize};
use tokio::io::AsyncReadExt;
use tokio::sync::{watch, Notify};
use k2v_client::{BatchDeleteOp, BatchReadOp, CausalityToken, Filter, K2vClient, K2vValue};
use rusoto_s3::{
DeleteObjectRequest, GetObjectRequest, ListObjectsV2Request, PutObjectRequest, S3Client, S3,
};
use crate::cryptoblob::*;
use crate::k2v_util::k2v_wait_value_changed;
use crate::login::Credentials;
use crate::time::now_msec;
use crate::storage;
use crate::timestamp::*;
const KEEP_STATE_EVERY: usize = 64;
@ -48,12 +41,10 @@ pub trait BayouState:
}
pub struct Bayou<S: BayouState> {
bucket: String,
path: String,
key: Key,
k2v: K2vClient,
s3: S3Client,
storage: storage::Store,
checkpoint: (Timestamp, S),
history: Vec<(Timestamp, S::Op, Option<S>)>,
@ -62,28 +53,27 @@ pub struct Bayou<S: BayouState> {
last_try_checkpoint: Option<Instant>,
watch: Arc<K2vWatch>,
last_sync_watch_ct: Option<CausalityToken>,
last_sync_watch_ct: storage::RowRef,
}
impl<S: BayouState> Bayou<S> {
pub fn new(creds: &Credentials, path: String) -> Result<Self> {
let k2v_client = creds.k2v_client()?;
let s3_client = creds.s3_client()?;
pub async fn new(creds: &Credentials, path: String) -> Result<Self> {
let storage = creds.storage.build().await?;
let watch = K2vWatch::new(creds, path.clone(), WATCH_SK.to_string())?;
//let target = k2v_client.row(&path, WATCH_SK);
let target = storage::RowRef::new(&path, WATCH_SK);
let watch = K2vWatch::new(creds, target.clone()).await?;
Ok(Self {
bucket: creds.bucket().to_string(),
path,
storage,
key: creds.keys.master.clone(),
k2v: k2v_client,
s3: s3_client,
checkpoint: (Timestamp::zero(), S::default()),
history: vec![],
last_sync: None,
last_try_checkpoint: None,
watch,
last_sync_watch_ct: None,
last_sync_watch_ct: target,
})
}
@ -94,28 +84,21 @@ impl<S: BayouState> Bayou<S> {
// 1. List checkpoints
let checkpoints = self.list_checkpoints().await?;
debug!("(sync) listed checkpoints: {:?}", checkpoints);
tracing::debug!("(sync) listed checkpoints: {:?}", checkpoints);
// 2. Load last checkpoint if different from currently used one
let checkpoint = if let Some((ts, key)) = checkpoints.last() {
if *ts == self.checkpoint.0 {
(*ts, None)
} else {
debug!("(sync) loading checkpoint: {}", key);
tracing::debug!("(sync) loading checkpoint: {}", key);
let gor = GetObjectRequest {
bucket: self.bucket.clone(),
key: key.to_string(),
..Default::default()
};
let obj_res = self.s3.get_object(gor).await?;
let obj_body = obj_res.body.ok_or(anyhow!("Missing object body"))?;
let mut buf = Vec::with_capacity(obj_res.content_length.unwrap_or(128) as usize);
obj_body.into_async_read().read_to_end(&mut buf).await?;
debug!("(sync) checkpoint body length: {}", buf.len());
let buf = self
.storage
.blob_fetch(&storage::BlobRef(key.to_string()))
.await?
.value;
tracing::debug!("(sync) checkpoint body length: {}", buf.len());
let ck = open_deserialize::<S>(&buf, &self.key)?;
(*ts, Some(ck))
@ -129,7 +112,7 @@ impl<S: BayouState> Bayou<S> {
}
if let Some(ck) = checkpoint.1 {
debug!(
tracing::debug!(
"(sync) updating checkpoint to loaded state at {:?}",
checkpoint.0
);
@ -144,49 +127,41 @@ impl<S: BayouState> Bayou<S> {
// 3. List all operations starting from checkpoint
let ts_ser = self.checkpoint.0.to_string();
debug!("(sync) looking up operations starting at {}", ts_ser);
tracing::debug!("(sync) looking up operations starting at {}", ts_ser);
let ops_map = self
.k2v
.read_batch(&[BatchReadOp {
partition_key: &self.path,
filter: Filter {
start: Some(&ts_ser),
end: Some(WATCH_SK),
prefix: None,
limit: None,
reverse: false,
},
single_item: false,
conflicts_only: false,
tombstones: false,
}])
.await?
.into_iter()
.next()
.ok_or(anyhow!("Missing K2V result"))?
.items;
.storage
.row_fetch(&storage::Selector::Range {
shard: &self.path,
sort_begin: &ts_ser,
sort_end: WATCH_SK,
})
.await?;
let mut ops = vec![];
for (tsstr, val) in ops_map {
let ts = tsstr
for row_value in ops_map {
let row = row_value.row_ref;
let sort_key = row.uid.sort;
let ts = sort_key
.parse::<Timestamp>()
.map_err(|_| anyhow!("Invalid operation timestamp: {}", tsstr))?;
if val.value.len() != 1 {
bail!("Invalid operation, has {} values", val.value.len());
.map_err(|_| anyhow!("Invalid operation timestamp: {}", sort_key))?;
let val = row_value.value;
if val.len() != 1 {
bail!("Invalid operation, has {} values", val.len());
}
match &val.value[0] {
K2vValue::Value(v) => {
match &val[0] {
storage::Alternative::Value(v) => {
let op = open_deserialize::<S::Op>(v, &self.key)?;
debug!("(sync) operation {}: {} {:?}", tsstr, base64::encode(v), op);
tracing::trace!("(sync) operation {}: {:?}", sort_key, op);
ops.push((ts, op));
}
K2vValue::Tombstone => {
unreachable!();
storage::Alternative::Tombstone => {
continue;
}
}
}
ops.sort_by_key(|(ts, _)| *ts);
debug!("(sync) {} operations", ops.len());
tracing::debug!("(sync) {} operations", ops.len());
if ops.len() < self.history.len() {
bail!("Some operations have disappeared from storage!");
@ -263,12 +238,16 @@ impl<S: BayouState> Bayou<S> {
Ok(())
}
pub fn notifier(&self) -> std::sync::Weak<Notify> {
Arc::downgrade(&self.watch.learnt_remote_update)
}
/// Applies a new operation on the state. Once this function returns,
/// the operation has been safely persisted to storage backend.
/// Make sure to call `.opportunistic_sync()` before doing this,
/// and even before calculating the `op` argument given here.
pub async fn push(&mut self, op: S::Op) -> Result<()> {
debug!("(push) add operation: {:?}", op);
tracing::debug!("(push) add operation: {:?}", op);
let ts = Timestamp::after(
self.history
@ -276,16 +255,13 @@ impl<S: BayouState> Bayou<S> {
.map(|(ts, _, _)| ts)
.unwrap_or(&self.checkpoint.0),
);
self.k2v
.insert_item(
&self.path,
&ts.to_string(),
seal_serialize(&op, &self.key)?,
None,
)
.await?;
self.watch.notify.notify_one();
let row_val = storage::RowVal::new(
storage::RowRef::new(&self.path, &ts.to_string()),
seal_serialize(&op, &self.key)?,
);
self.storage.row_insert(vec![row_val]).await?;
self.watch.propagate_local_update.notify_one();
let new_state = self.state().apply(&op);
self.history.push((ts, op, Some(new_state)));
@ -333,18 +309,18 @@ impl<S: BayouState> Bayou<S> {
{
Some(i) => i,
None => {
debug!("(cp) Oldest operation is too recent to trigger checkpoint");
tracing::debug!("(cp) Oldest operation is too recent to trigger checkpoint");
return Ok(());
}
};
if i_cp < CHECKPOINT_MIN_OPS {
debug!("(cp) Not enough old operations to trigger checkpoint");
tracing::debug!("(cp) Not enough old operations to trigger checkpoint");
return Ok(());
}
let ts_cp = self.history[i_cp].0;
debug!(
tracing::debug!(
"(cp) we could checkpoint at time {} (index {} in history)",
ts_cp.to_string(),
i_cp
@ -352,13 +328,13 @@ impl<S: BayouState> Bayou<S> {
// Check existing checkpoints: if last one is too recent, don't checkpoint again.
let existing_checkpoints = self.list_checkpoints().await?;
debug!("(cp) listed checkpoints: {:?}", existing_checkpoints);
tracing::debug!("(cp) listed checkpoints: {:?}", existing_checkpoints);
if let Some(last_cp) = existing_checkpoints.last() {
if (ts_cp.msec as i128 - last_cp.0.msec as i128)
< CHECKPOINT_INTERVAL.as_millis() as i128
{
debug!(
tracing::debug!(
"(cp) last checkpoint is too recent: {}, not checkpointing",
last_cp.0.to_string()
);
@ -366,7 +342,7 @@ impl<S: BayouState> Bayou<S> {
}
}
debug!("(cp) saving checkpoint at {}", ts_cp.to_string());
tracing::debug!("(cp) saving checkpoint at {}", ts_cp.to_string());
// Calculate state at time of checkpoint
let mut last_known_state = (0, &self.checkpoint.1);
@ -382,15 +358,13 @@ impl<S: BayouState> Bayou<S> {
// Serialize and save checkpoint
let cryptoblob = seal_serialize(&state_cp, &self.key)?;
debug!("(cp) checkpoint body length: {}", cryptoblob.len());
tracing::debug!("(cp) checkpoint body length: {}", cryptoblob.len());
let por = PutObjectRequest{
bucket: self.bucket.clone(),
key: format!("{}/checkpoint/{}", self.path, ts_cp.to_string()),
body: Some(cryptoblob.into()),
..Default::default()
};
self.s3.put_object(por).await?;
let blob_val = storage::BlobVal::new(
storage::BlobRef(format!("{}/checkpoint/{}", self.path, ts_cp.to_string())),
cryptoblob.into(),
);
self.storage.blob_insert(blob_val).await?;
// Drop old checkpoints (but keep at least CHECKPOINTS_TO_KEEP of them)
let ecp_len = existing_checkpoints.len();
@ -399,26 +373,21 @@ impl<S: BayouState> Bayou<S> {
// Delete blobs
for (_ts, key) in existing_checkpoints[..last_to_keep].iter() {
debug!("(cp) drop old checkpoint {}", key);
let dor = DeleteObjectRequest {
bucket: self.bucket.clone(),
key: key.to_string(),
..Default::default()
};
self.s3.delete_object(dor).await?;
tracing::debug!("(cp) drop old checkpoint {}", key);
self.storage
.blob_rm(&storage::BlobRef(key.to_string()))
.await?;
}
// Delete corresponding range of operations
let ts_ser = existing_checkpoints[last_to_keep].0.to_string();
self.k2v
.delete_batch(&[BatchDeleteOp {
partition_key: &self.path,
prefix: None,
start: None,
end: Some(&ts_ser),
single_item: false,
}])
.await?;
self.storage
.row_rm(&storage::Selector::Range {
shard: &self.path,
sort_begin: "",
sort_end: &ts_ser,
})
.await?
}
Ok(())
@ -437,22 +406,14 @@ impl<S: BayouState> Bayou<S> {
async fn list_checkpoints(&self) -> Result<Vec<(Timestamp, String)>> {
let prefix = format!("{}/checkpoint/", self.path);
let lor = ListObjectsV2Request{
bucket: self.bucket.clone(),
max_keys: Some(1000),
prefix: Some(prefix.clone()),
..Default::default()
};
let checkpoints_res = self.s3.list_objects_v2(lor).await?;
let checkpoints_res = self.storage.blob_list(&prefix).await?;
let mut checkpoints = vec![];
for object in checkpoints_res.contents.unwrap_or_default() {
if let Some(key) = object.key {
if let Some(ckid) = key.strip_prefix(&prefix) {
if let Ok(ts) = ckid.parse::<Timestamp>() {
checkpoints.push((ts, key));
}
for object in checkpoints_res {
let key = object.0;
if let Some(ckid) = key.strip_prefix(&prefix) {
if let Ok(ts) = ckid.parse::<Timestamp>() {
checkpoints.push((ts, key.into()));
}
}
}
@ -464,131 +425,90 @@ impl<S: BayouState> Bayou<S> {
// ---- Bayou watch in K2V ----
struct K2vWatch {
pk: String,
sk: String,
rx: watch::Receiver<Option<CausalityToken>>,
notify: Notify,
target: storage::RowRef,
rx: watch::Receiver<storage::RowRef>,
propagate_local_update: Notify,
learnt_remote_update: Arc<Notify>,
}
impl K2vWatch {
/// Creates a new watch and launches subordinate threads.
/// These threads hold Weak pointers to the struct;
/// the exit when the Arc is dropped.
fn new(creds: &Credentials, pk: String, sk: String) -> Result<Arc<Self>> {
let (tx, rx) = watch::channel::<Option<CausalityToken>>(None);
let notify = Notify::new();
/// they exit when the Arc is dropped.
async fn new(creds: &Credentials, target: storage::RowRef) -> Result<Arc<Self>> {
let storage = creds.storage.build().await?;
let watch = Arc::new(K2vWatch { pk, sk, rx, notify });
let (tx, rx) = watch::channel::<storage::RowRef>(target.clone());
let propagate_local_update = Notify::new();
let learnt_remote_update = Arc::new(Notify::new());
tokio::spawn(Self::background_task(
Arc::downgrade(&watch),
creds.k2v_client()?,
tx,
));
let watch = Arc::new(K2vWatch {
target,
rx,
propagate_local_update,
learnt_remote_update,
});
tokio::spawn(Self::background_task(Arc::downgrade(&watch), storage, tx));
Ok(watch)
}
async fn background_task(
self_weak: Weak<Self>,
k2v: K2vClient,
tx: watch::Sender<Option<CausalityToken>>,
storage: storage::Store,
tx: watch::Sender<storage::RowRef>,
) {
let mut ct = None;
let (mut row, remote_update) = match Weak::upgrade(&self_weak) {
Some(this) => (this.target.clone(), this.learnt_remote_update.clone()),
None => return,
};
while let Some(this) = Weak::upgrade(&self_weak) {
debug!(
"bayou k2v watch bg loop iter ({}, {}): ct = {:?}",
this.pk, this.sk, ct
tracing::debug!(
"bayou k2v watch bg loop iter ({}, {})",
this.target.uid.shard,
this.target.uid.sort
);
tokio::select!(
// Needed to exit: will force a loop iteration every minutes,
// that will stop the loop if other Arc references have been dropped
// and free resources. Otherwise we would be blocked waiting forever...
_ = tokio::time::sleep(Duration::from_secs(60)) => continue,
update = k2v_wait_value_changed(&k2v, &this.pk, &this.sk, &ct) => {
// Watch if another instance has modified the log
update = storage.row_poll(&row) => {
match update {
Err(e) => {
error!("Error in bayou k2v wait value changed: {}", e);
tokio::time::sleep(Duration::from_secs(30)).await;
}
Ok(cv) => {
if tx.send(Some(cv.causality.clone())).is_err() {
Ok(new_value) => {
row = new_value.row_ref;
if let Err(e) = tx.send(row.clone()) {
tracing::warn!(err=?e, "(watch) can't record the new log ref");
break;
}
ct = Some(cv.causality);
tracing::debug!(row=?row, "(watch) learnt remote update");
this.learnt_remote_update.notify_waiters();
}
}
}
_ = this.notify.notified() => {
// It appears we have modified the log, informing other people
_ = this.propagate_local_update.notified() => {
let rand = u128::to_be_bytes(thread_rng().gen()).to_vec();
if let Err(e) = k2v
.insert_item(
&this.pk,
&this.sk,
rand,
ct.clone(),
)
.await
let row_val = storage::RowVal::new(row.clone(), rand);
if let Err(e) = storage.row_insert(vec![row_val]).await
{
error!("Error in bayou k2v watch updater loop: {}", e);
tracing::error!("Error in bayou k2v watch updater loop: {}", e);
tokio::time::sleep(Duration::from_secs(30)).await;
}
}
);
}
info!("bayou k2v watch bg loop exiting");
}
}
// ---- TIMESTAMP CLASS ----
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub struct Timestamp {
pub msec: u64,
pub rand: u64,
}
impl Timestamp {
#[allow(dead_code)]
// 2023-05-15 try to make clippy happy and not sure if this fn will be used in the future.
pub fn now() -> Self {
let mut rng = thread_rng();
Self {
msec: now_msec(),
rand: rng.gen::<u64>(),
}
}
pub fn after(other: &Self) -> Self {
let mut rng = thread_rng();
Self {
msec: std::cmp::max(now_msec(), other.msec + 1),
rand: rng.gen::<u64>(),
}
}
pub fn zero() -> Self {
Self { msec: 0, rand: 0 }
}
}
impl ToString for Timestamp {
fn to_string(&self) -> String {
let mut bytes = [0u8; 16];
bytes[0..8].copy_from_slice(&u64::to_be_bytes(self.msec));
bytes[8..16].copy_from_slice(&u64::to_be_bytes(self.rand));
hex::encode(bytes)
}
}
impl FromStr for Timestamp {
type Err = &'static str;
fn from_str(s: &str) -> Result<Timestamp, &'static str> {
let bytes = hex::decode(s).map_err(|_| "invalid hex")?;
if bytes.len() != 16 {
return Err("bad length");
}
Ok(Self {
msec: u64::from_be_bytes(bytes[0..8].try_into().unwrap()),
rand: u64::from_be_bytes(bytes[8..16].try_into().unwrap()),
})
// unblock listeners
remote_update.notify_waiters();
tracing::info!("bayou k2v watch bg loop exiting");
}
}

View file

@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::io::Read;
use std::io::{Read, Write};
use std::net::SocketAddr;
use std::path::PathBuf;
@ -7,63 +7,35 @@ use anyhow::Result;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Config {
pub s3_endpoint: String,
pub k2v_endpoint: String,
pub aws_region: String,
pub struct CompanionConfig {
pub pid: Option<PathBuf>,
pub imap: ImapUnsecureConfig,
pub login_static: Option<LoginStaticConfig>,
pub login_ldap: Option<LoginLdapConfig>,
#[serde(flatten)]
pub users: LoginStaticConfig,
}
pub lmtp: Option<LmtpConfig>,
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ProviderConfig {
pub pid: Option<PathBuf>,
pub imap: Option<ImapConfig>,
pub imap_unsecure: Option<ImapUnsecureConfig>,
pub lmtp: Option<LmtpConfig>,
pub auth: Option<AuthConfig>,
pub users: UserManagement,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LoginStaticConfig {
pub default_bucket: Option<String>,
pub users: HashMap<String, LoginStaticUser>,
#[serde(tag = "user_driver")]
pub enum UserManagement {
Demo,
Static(LoginStaticConfig),
Ldap(LoginLdapConfig),
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LoginStaticUser {
#[serde(default)]
pub email_addresses: Vec<String>,
pub password: String,
pub aws_access_key_id: String,
pub aws_secret_access_key: String,
pub bucket: Option<String>,
pub user_secret: String,
#[serde(default)]
pub alternate_user_secrets: Vec<String>,
pub master_key: Option<String>,
pub secret_key: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LoginLdapConfig {
pub ldap_server: String,
#[serde(default)]
pub pre_bind_on_login: bool,
pub bind_dn: Option<String>,
pub bind_password: Option<String>,
pub search_base: String,
pub username_attr: String,
#[serde(default = "default_mail_attr")]
pub mail_attr: String,
pub aws_access_key_id_attr: String,
pub aws_secret_access_key_attr: String,
pub user_secret_attr: String,
pub alternate_user_secrets_attr: Option<String>,
pub bucket: Option<String>,
pub bucket_attr: Option<String>,
pub struct AuthConfig {
pub bind_addr: SocketAddr,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
@ -75,9 +47,116 @@ pub struct LmtpConfig {
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ImapConfig {
pub bind_addr: SocketAddr,
pub certs: PathBuf,
pub key: PathBuf,
}
pub fn read_config(config_file: PathBuf) -> Result<Config> {
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ImapUnsecureConfig {
pub bind_addr: SocketAddr,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LoginStaticConfig {
pub user_list: PathBuf,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "storage_driver")]
pub enum LdapStorage {
Garage(LdapGarageConfig),
InMemory,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LdapGarageConfig {
pub s3_endpoint: String,
pub k2v_endpoint: String,
pub aws_region: String,
pub aws_access_key_id_attr: String,
pub aws_secret_access_key_attr: String,
pub bucket_attr: Option<String>,
pub default_bucket: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LoginLdapConfig {
// LDAP connection info
pub ldap_server: String,
#[serde(default)]
pub pre_bind_on_login: bool,
pub bind_dn: Option<String>,
pub bind_password: Option<String>,
pub search_base: String,
// Schema-like info required for Aerogramme's logic
pub username_attr: String,
#[serde(default = "default_mail_attr")]
pub mail_attr: String,
// The field that will contain the crypto root thingy
pub crypto_root_attr: String,
// Storage related thing
#[serde(flatten)]
pub storage: LdapStorage,
}
// ----
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "storage_driver")]
pub enum StaticStorage {
Garage(StaticGarageConfig),
InMemory,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct StaticGarageConfig {
pub s3_endpoint: String,
pub k2v_endpoint: String,
pub aws_region: String,
pub aws_access_key_id: String,
pub aws_secret_access_key: String,
pub bucket: String,
}
pub type UserList = HashMap<String, UserEntry>;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UserEntry {
#[serde(default)]
pub email_addresses: Vec<String>,
pub password: String,
pub crypto_root: String,
#[serde(flatten)]
pub storage: StaticStorage,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SetupEntry {
#[serde(default)]
pub email_addresses: Vec<String>,
#[serde(default)]
pub clear_password: Option<String>,
#[serde(flatten)]
pub storage: StaticStorage,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "role")]
pub enum AnyConfig {
Companion(CompanionConfig),
Provider(ProviderConfig),
}
// ---
pub fn read_config<T: serde::de::DeserializeOwned>(config_file: PathBuf) -> Result<T> {
let mut file = std::fs::OpenOptions::new()
.read(true)
.open(config_file.as_path())?;
@ -88,6 +167,18 @@ pub fn read_config(config_file: PathBuf) -> Result<Config> {
Ok(toml::from_str(&config)?)
}
pub fn write_config<T: Serialize>(config_file: PathBuf, config: &T) -> Result<()> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(config_file.as_path())?;
file.write_all(toml::to_string(config)?.as_bytes())?;
Ok(())
}
fn default_mail_attr() -> String {
"mail".into()
}

77
src/imap/attributes.rs Normal file
View file

@ -0,0 +1,77 @@
use imap_codec::imap_types::command::FetchModifier;
use imap_codec::imap_types::fetch::{MacroOrMessageDataItemNames, MessageDataItemName, Section};
/// Internal decisions based on fetched attributes
/// passed by the client
pub struct AttributesProxy {
pub attrs: Vec<MessageDataItemName<'static>>,
}
impl AttributesProxy {
pub fn new(
attrs: &MacroOrMessageDataItemNames<'static>,
modifiers: &[FetchModifier],
is_uid_fetch: bool,
) -> Self {
// Expand macros
let mut fetch_attrs = match attrs {
MacroOrMessageDataItemNames::Macro(m) => {
use imap_codec::imap_types::fetch::Macro;
use MessageDataItemName::*;
match m {
Macro::All => vec![Flags, InternalDate, Rfc822Size, Envelope],
Macro::Fast => vec![Flags, InternalDate, Rfc822Size],
Macro::Full => vec![Flags, InternalDate, Rfc822Size, Envelope, Body],
_ => {
tracing::error!("unimplemented macro");
vec![]
}
}
}
MacroOrMessageDataItemNames::MessageDataItemNames(a) => a.clone(),
};
// Handle uids
if is_uid_fetch && !fetch_attrs.contains(&MessageDataItemName::Uid) {
fetch_attrs.push(MessageDataItemName::Uid);
}
// Handle inferred MODSEQ tag
let is_changed_since = modifiers
.iter()
.any(|m| matches!(m, FetchModifier::ChangedSince(..)));
if is_changed_since && !fetch_attrs.contains(&MessageDataItemName::ModSeq) {
fetch_attrs.push(MessageDataItemName::ModSeq);
}
Self { attrs: fetch_attrs }
}
pub fn is_enabling_condstore(&self) -> bool {
self.attrs
.iter()
.any(|x| matches!(x, MessageDataItemName::ModSeq))
}
pub fn need_body(&self) -> bool {
self.attrs.iter().any(|x| match x {
MessageDataItemName::Body
| MessageDataItemName::Rfc822
| MessageDataItemName::Rfc822Text
| MessageDataItemName::BodyStructure => true,
MessageDataItemName::BodyExt {
section: Some(section),
partial: _,
peek: _,
} => match section {
Section::Header(None)
| Section::HeaderFields(None, _)
| Section::HeaderFieldsNot(None, _) => false,
_ => true,
},
MessageDataItemName::BodyExt { .. } => true,
_ => false,
})
}
}

159
src/imap/capability.rs Normal file
View file

@ -0,0 +1,159 @@
use imap_codec::imap_types::command::{FetchModifier, SelectExamineModifier, StoreModifier};
use imap_codec::imap_types::core::Vec1;
use imap_codec::imap_types::extensions::enable::{CapabilityEnable, Utf8Kind};
use imap_codec::imap_types::response::Capability;
use std::collections::HashSet;
use crate::imap::attributes::AttributesProxy;
fn capability_unselect() -> Capability<'static> {
Capability::try_from("UNSELECT").unwrap()
}
fn capability_condstore() -> Capability<'static> {
Capability::try_from("CONDSTORE").unwrap()
}
fn capability_uidplus() -> Capability<'static> {
Capability::try_from("UIDPLUS").unwrap()
}
fn capability_liststatus() -> Capability<'static> {
Capability::try_from("LIST-STATUS").unwrap()
}
/*
fn capability_qresync() -> Capability<'static> {
Capability::try_from("QRESYNC").unwrap()
}
*/
#[derive(Debug, Clone)]
pub struct ServerCapability(HashSet<Capability<'static>>);
impl Default for ServerCapability {
fn default() -> Self {
Self(HashSet::from([
Capability::Imap4Rev1,
Capability::Enable,
Capability::Move,
Capability::LiteralPlus,
Capability::Idle,
capability_unselect(),
capability_condstore(),
capability_uidplus(),
capability_liststatus(),
//capability_qresync(),
]))
}
}
impl ServerCapability {
pub fn to_vec(&self) -> Vec1<Capability<'static>> {
self.0
.iter()
.map(|v| v.clone())
.collect::<Vec<_>>()
.try_into()
.unwrap()
}
#[allow(dead_code)]
pub fn support(&self, cap: &Capability<'static>) -> bool {
self.0.contains(cap)
}
}
#[derive(Clone)]
pub enum ClientStatus {
NotSupportedByServer,
Disabled,
Enabled,
}
impl ClientStatus {
pub fn is_enabled(&self) -> bool {
matches!(self, Self::Enabled)
}
pub fn enable(&self) -> Self {
match self {
Self::Disabled => Self::Enabled,
other => other.clone(),
}
}
}
pub struct ClientCapability {
pub condstore: ClientStatus,
pub utf8kind: Option<Utf8Kind>,
}
impl ClientCapability {
pub fn new(sc: &ServerCapability) -> Self {
Self {
condstore: match sc.0.contains(&capability_condstore()) {
true => ClientStatus::Disabled,
_ => ClientStatus::NotSupportedByServer,
},
utf8kind: None,
}
}
pub fn enable_condstore(&mut self) {
self.condstore = self.condstore.enable();
}
pub fn attributes_enable(&mut self, ap: &AttributesProxy) {
if ap.is_enabling_condstore() {
self.enable_condstore()
}
}
pub fn fetch_modifiers_enable(&mut self, mods: &[FetchModifier]) {
if mods
.iter()
.any(|x| matches!(x, FetchModifier::ChangedSince(..)))
{
self.enable_condstore()
}
}
pub fn store_modifiers_enable(&mut self, mods: &[StoreModifier]) {
if mods
.iter()
.any(|x| matches!(x, StoreModifier::UnchangedSince(..)))
{
self.enable_condstore()
}
}
pub fn select_enable(&mut self, mods: &[SelectExamineModifier]) {
for m in mods.iter() {
match m {
SelectExamineModifier::Condstore => self.enable_condstore(),
}
}
}
pub fn try_enable(
&mut self,
caps: &[CapabilityEnable<'static>],
) -> Vec<CapabilityEnable<'static>> {
let mut enabled = vec![];
for cap in caps {
match cap {
CapabilityEnable::CondStore if matches!(self.condstore, ClientStatus::Disabled) => {
self.condstore = ClientStatus::Enabled;
enabled.push(cap.clone());
}
CapabilityEnable::Utf8(kind) if Some(kind) != self.utf8kind.as_ref() => {
self.utf8kind = Some(kind.clone());
enabled.push(cap.clone());
}
_ => (),
}
}
enabled
}
}

View file

@ -1,92 +1,83 @@
use anyhow::{Error, Result};
use boitalettres::proto::{res::body::Data as Body, Request, Response};
use imap_codec::types::command::CommandBody;
use imap_codec::types::core::AString;
use imap_codec::types::response::{Capability, Data, Status};
use anyhow::Result;
use imap_codec::imap_types::command::{Command, CommandBody};
use imap_codec::imap_types::core::AString;
use imap_codec::imap_types::response::Code;
use imap_codec::imap_types::secret::Secret;
use crate::imap::capability::ServerCapability;
use crate::imap::command::anystate;
use crate::imap::flow;
use crate::imap::response::Response;
use crate::login::ArcLoginProvider;
use crate::mail::user::User;
//--- dispatching
pub struct AnonymousContext<'a> {
pub req: &'a Request,
pub login_provider: Option<&'a ArcLoginProvider>,
pub req: &'a Command<'static>,
pub server_capabilities: &'a ServerCapability,
pub login_provider: &'a ArcLoginProvider,
}
pub async fn dispatch(ctx: AnonymousContext<'_>) -> Result<(Response, flow::Transition)> {
match &ctx.req.command.body {
CommandBody::Noop => Ok((Response::ok("Noop completed.")?, flow::Transition::None)),
CommandBody::Capability => ctx.capability().await,
CommandBody::Logout => ctx.logout().await,
pub async fn dispatch(ctx: AnonymousContext<'_>) -> Result<(Response<'static>, flow::Transition)> {
match &ctx.req.body {
// Any State
CommandBody::Noop => anystate::noop_nothing(ctx.req.tag.clone()),
CommandBody::Capability => {
anystate::capability(ctx.req.tag.clone(), ctx.server_capabilities)
}
CommandBody::Logout => anystate::logout(),
// Specific to anonymous context (3 commands)
CommandBody::Login { username, password } => ctx.login(username, password).await,
_ => Ok((Response::no("Command unavailable")?, flow::Transition::None)),
CommandBody::Authenticate { .. } => {
anystate::not_implemented(ctx.req.tag.clone(), "authenticate")
}
//StartTLS is not implemented for now, we will probably go full TLS.
// Collect other commands
_ => anystate::wrong_state(ctx.req.tag.clone()),
}
}
//--- Command controllers, private
impl<'a> AnonymousContext<'a> {
async fn capability(self) -> Result<(Response, flow::Transition)> {
let capabilities = vec![Capability::Imap4Rev1, Capability::Idle];
let res = Response::ok("Server capabilities")?.with_body(Data::Capability(capabilities));
Ok((res, flow::Transition::None))
}
async fn login(
self,
username: &AString,
password: &AString,
) -> Result<(Response, flow::Transition)> {
username: &AString<'a>,
password: &Secret<AString<'a>>,
) -> Result<(Response<'static>, flow::Transition)> {
let (u, p) = (
String::try_from(username.clone())?,
String::try_from(password.clone())?,
std::str::from_utf8(username.as_ref())?,
std::str::from_utf8(password.declassify().as_ref())?,
);
tracing::info!(user = %u, "command.login");
let login_provider = match &self.login_provider {
Some(lp) => lp,
None => {
return Ok((
Response::no("Login command not available (already logged in)")?,
flow::Transition::None,
))
}
};
let creds = match login_provider.login(&u, &p).await {
let creds = match self.login_provider.login(&u, &p).await {
Err(e) => {
tracing::debug!(error=%e, "authentication failed");
return Ok((
Response::no("Authentication failed")?,
Response::build()
.to_req(self.req)
.message("Authentication failed")
.no()?,
flow::Transition::None,
));
}
Ok(c) => c,
};
let user = User::new(u.clone(), creds).await?;
let user = User::new(u.to_string(), creds).await?;
tracing::info!(username=%u, "connected");
Ok((
Response::ok("Completed")?,
Response::build()
.to_req(self.req)
.code(Code::Capability(self.server_capabilities.to_vec()))
.message("Completed")
.ok()?,
flow::Transition::Authenticate(user),
))
}
// C: 10 logout
// S: * BYE Logging out
// S: 10 OK Logout completed.
async fn logout(self) -> Result<(Response, flow::Transition)> {
// @FIXME we should implement From<Vec<Status>> and From<Vec<ImapStatus>> in
// boitalettres/src/proto/res/body.rs
Ok((
Response::ok("Logout completed")?.with_body(vec![Body::Status(
Status::bye(None, "Logging out")
.map_err(|e| Error::msg(e).context("Unable to generate IMAP status"))?,
)]),
flow::Transition::Logout,
))
}
}

View file

@ -0,0 +1,54 @@
use anyhow::Result;
use imap_codec::imap_types::core::Tag;
use imap_codec::imap_types::response::Data;
use crate::imap::capability::ServerCapability;
use crate::imap::flow;
use crate::imap::response::Response;
pub(crate) fn capability(
tag: Tag<'static>,
cap: &ServerCapability,
) -> Result<(Response<'static>, flow::Transition)> {
let res = Response::build()
.tag(tag)
.message("Server capabilities")
.data(Data::Capability(cap.to_vec()))
.ok()?;
Ok((res, flow::Transition::None))
}
pub(crate) fn noop_nothing(tag: Tag<'static>) -> Result<(Response<'static>, flow::Transition)> {
Ok((
Response::build().tag(tag).message("Noop completed.").ok()?,
flow::Transition::None,
))
}
pub(crate) fn logout() -> Result<(Response<'static>, flow::Transition)> {
Ok((Response::bye()?, flow::Transition::Logout))
}
pub(crate) fn not_implemented<'a>(
tag: Tag<'a>,
what: &str,
) -> Result<(Response<'a>, flow::Transition)> {
Ok((
Response::build()
.tag(tag)
.message(format!("Command not implemented {}", what))
.bad()?,
flow::Transition::None,
))
}
pub(crate) fn wrong_state(tag: Tag<'static>) -> Result<(Response<'static>, flow::Transition)> {
Ok((
Response::build()
.tag(tag)
.message("Command not authorized in this state")
.bad()?,
flow::Transition::None,
))
}

View file

@ -1,144 +1,226 @@
use std::collections::BTreeMap;
use std::sync::Arc;
use thiserror::Error;
use anyhow::{anyhow, bail, Result};
use boitalettres::proto::res::body::Data as Body;
use boitalettres::proto::{Request, Response};
use imap_codec::types::command::{CommandBody, StatusAttribute};
use imap_codec::types::core::NonZeroBytes;
use imap_codec::types::datetime::MyDateTime;
use imap_codec::types::flag::{Flag, FlagNameAttribute};
use imap_codec::types::mailbox::{ListMailbox, Mailbox as MailboxCodec};
use imap_codec::types::response::{Code, Data, StatusAttributeValue};
use imap_codec::imap_types::command::{
Command, CommandBody, ListReturnItem, SelectExamineModifier,
};
use imap_codec::imap_types::core::{Atom, Literal, QuotedChar, Vec1};
use imap_codec::imap_types::datetime::DateTime;
use imap_codec::imap_types::extensions::enable::CapabilityEnable;
use imap_codec::imap_types::flag::{Flag, FlagNameAttribute};
use imap_codec::imap_types::mailbox::{ListMailbox, Mailbox as MailboxCodec};
use imap_codec::imap_types::response::{Code, CodeOther, Data};
use imap_codec::imap_types::status::{StatusDataItem, StatusDataItemName};
use crate::imap::command::anonymous;
use crate::imap::capability::{ClientCapability, ServerCapability};
use crate::imap::command::{anystate, MailboxName};
use crate::imap::flow;
use crate::imap::mailbox_view::MailboxView;
use crate::imap::mailbox_view::{MailboxView, UpdateParameters};
use crate::imap::response::Response;
use crate::imap::Body;
use crate::mail::mailbox::Mailbox;
use crate::mail::uidindex::*;
use crate::mail::user::{User, INBOX, MAILBOX_HIERARCHY_DELIMITER};
use crate::mail::user::{User, MAILBOX_HIERARCHY_DELIMITER as MBX_HIER_DELIM_RAW};
use crate::mail::IMF;
pub struct AuthenticatedContext<'a> {
pub req: &'a Request,
pub req: &'a Command<'static>,
pub server_capabilities: &'a ServerCapability,
pub client_capabilities: &'a mut ClientCapability,
pub user: &'a Arc<User>,
}
pub async fn dispatch(ctx: AuthenticatedContext<'_>) -> Result<(Response, flow::Transition)> {
match &ctx.req.command.body {
pub async fn dispatch<'a>(
mut ctx: AuthenticatedContext<'a>,
) -> Result<(Response<'static>, flow::Transition)> {
match &ctx.req.body {
// Any state
CommandBody::Noop => anystate::noop_nothing(ctx.req.tag.clone()),
CommandBody::Capability => {
anystate::capability(ctx.req.tag.clone(), ctx.server_capabilities)
}
CommandBody::Logout => anystate::logout(),
// Specific to this state (11 commands)
CommandBody::Create { mailbox } => ctx.create(mailbox).await,
CommandBody::Delete { mailbox } => ctx.delete(mailbox).await,
CommandBody::Rename {
mailbox,
new_mailbox,
} => ctx.rename(mailbox, new_mailbox).await,
CommandBody::Rename { from, to } => ctx.rename(from, to).await,
CommandBody::Lsub {
reference,
mailbox_wildcard,
} => ctx.list(reference, mailbox_wildcard, true).await,
} => ctx.list(reference, mailbox_wildcard, &[], true).await,
CommandBody::List {
reference,
mailbox_wildcard,
} => ctx.list(reference, mailbox_wildcard, false).await,
r#return,
} => ctx.list(reference, mailbox_wildcard, r#return, false).await,
CommandBody::Status {
mailbox,
attributes,
} => ctx.status(mailbox, attributes).await,
item_names,
} => ctx.status(mailbox, item_names).await,
CommandBody::Subscribe { mailbox } => ctx.subscribe(mailbox).await,
CommandBody::Unsubscribe { mailbox } => ctx.unsubscribe(mailbox).await,
CommandBody::Select { mailbox } => ctx.select(mailbox).await,
CommandBody::Examine { mailbox } => ctx.examine(mailbox).await,
CommandBody::Select { mailbox, modifiers } => ctx.select(mailbox, modifiers).await,
CommandBody::Examine { mailbox, modifiers } => ctx.examine(mailbox, modifiers).await,
CommandBody::Append {
mailbox,
flags,
date,
message,
} => ctx.append(mailbox, flags, date, message).await,
_ => {
let ctx = anonymous::AnonymousContext {
req: ctx.req,
login_provider: None,
};
anonymous::dispatch(ctx).await
}
// rfc5161 ENABLE
CommandBody::Enable { capabilities } => ctx.enable(capabilities),
// Collect other commands
_ => anystate::wrong_state(ctx.req.tag.clone()),
}
}
// --- PRIVATE ---
impl<'a> AuthenticatedContext<'a> {
async fn create(self, mailbox: &MailboxCodec) -> Result<(Response, flow::Transition)> {
let name = String::try_from(mailbox.clone())?;
if name == INBOX {
return Ok((
Response::bad("Cannot create INBOX")?,
flow::Transition::None,
));
}
async fn create(
self,
mailbox: &MailboxCodec<'a>,
) -> Result<(Response<'static>, flow::Transition)> {
let name = match mailbox {
MailboxCodec::Inbox => {
return Ok((
Response::build()
.to_req(self.req)
.message("Cannot create INBOX")
.bad()?,
flow::Transition::None,
));
}
MailboxCodec::Other(aname) => std::str::from_utf8(aname.as_ref())?,
};
match self.user.create_mailbox(&name).await {
Ok(()) => Ok((Response::ok("CREATE complete")?, flow::Transition::None)),
Err(e) => Ok((Response::no(&e.to_string())?, flow::Transition::None)),
Ok(()) => Ok((
Response::build()
.to_req(self.req)
.message("CREATE complete")
.ok()?,
flow::Transition::None,
)),
Err(e) => Ok((
Response::build()
.to_req(self.req)
.message(&e.to_string())
.no()?,
flow::Transition::None,
)),
}
}
async fn delete(self, mailbox: &MailboxCodec) -> Result<(Response, flow::Transition)> {
let name = String::try_from(mailbox.clone())?;
async fn delete(
self,
mailbox: &MailboxCodec<'a>,
) -> Result<(Response<'static>, flow::Transition)> {
let name: &str = MailboxName(mailbox).try_into()?;
match self.user.delete_mailbox(&name).await {
Ok(()) => Ok((Response::ok("DELETE complete")?, flow::Transition::None)),
Err(e) => Ok((Response::no(&e.to_string())?, flow::Transition::None)),
Ok(()) => Ok((
Response::build()
.to_req(self.req)
.message("DELETE complete")
.ok()?,
flow::Transition::None,
)),
Err(e) => Ok((
Response::build()
.to_req(self.req)
.message(e.to_string())
.no()?,
flow::Transition::None,
)),
}
}
async fn rename(
self,
mailbox: &MailboxCodec,
new_mailbox: &MailboxCodec,
) -> Result<(Response, flow::Transition)> {
let name = String::try_from(mailbox.clone())?;
let new_name = String::try_from(new_mailbox.clone())?;
from: &MailboxCodec<'a>,
to: &MailboxCodec<'a>,
) -> Result<(Response<'static>, flow::Transition)> {
let name: &str = MailboxName(from).try_into()?;
let new_name: &str = MailboxName(to).try_into()?;
match self.user.rename_mailbox(&name, &new_name).await {
Ok(()) => Ok((Response::ok("RENAME complete")?, flow::Transition::None)),
Err(e) => Ok((Response::no(&e.to_string())?, flow::Transition::None)),
Ok(()) => Ok((
Response::build()
.to_req(self.req)
.message("RENAME complete")
.ok()?,
flow::Transition::None,
)),
Err(e) => Ok((
Response::build()
.to_req(self.req)
.message(e.to_string())
.no()?,
flow::Transition::None,
)),
}
}
async fn list(
self,
reference: &MailboxCodec,
mailbox_wildcard: &ListMailbox,
&mut self,
reference: &MailboxCodec<'a>,
mailbox_wildcard: &ListMailbox<'a>,
must_return: &[ListReturnItem],
is_lsub: bool,
) -> Result<(Response, flow::Transition)> {
let reference = String::try_from(reference.clone())?;
) -> Result<(Response<'static>, flow::Transition)> {
let mbx_hier_delim: QuotedChar = QuotedChar::unvalidated(MBX_HIER_DELIM_RAW);
let reference: &str = MailboxName(reference).try_into()?;
if !reference.is_empty() {
return Ok((
Response::bad("References not supported")?,
Response::build()
.to_req(self.req)
.message("References not supported")
.bad()?,
flow::Transition::None,
));
}
let wildcard = String::try_from(mailbox_wildcard.clone())?;
let status_item_names = must_return.iter().find_map(|m| match m {
ListReturnItem::Status(v) => Some(v),
_ => None,
});
// @FIXME would probably need a rewrite to better use the imap_codec library
let wildcard = match mailbox_wildcard {
ListMailbox::Token(v) => std::str::from_utf8(v.as_ref())?,
ListMailbox::String(v) => std::str::from_utf8(v.as_ref())?,
};
if wildcard.is_empty() {
if is_lsub {
return Ok((
Response::ok("LSUB complete")?.with_body(vec![Data::Lsub {
items: vec![],
delimiter: Some(MAILBOX_HIERARCHY_DELIMITER),
mailbox: "".try_into().unwrap(),
}]),
Response::build()
.to_req(self.req)
.message("LSUB complete")
.data(Data::Lsub {
items: vec![],
delimiter: Some(mbx_hier_delim),
mailbox: "".try_into().unwrap(),
})
.ok()?,
flow::Transition::None,
));
} else {
return Ok((
Response::ok("LIST complete")?.with_body(vec![Data::List {
items: vec![],
delimiter: Some(MAILBOX_HIERARCHY_DELIMITER),
mailbox: "".try_into().unwrap(),
}]),
Response::build()
.to_req(self.req)
.message("LIST complete")
.data(Data::List {
items: vec![],
delimiter: Some(mbx_hier_delim),
mailbox: "".try_into().unwrap(),
})
.ok()?,
flow::Transition::None,
));
}
@ -147,7 +229,7 @@ impl<'a> AuthenticatedContext<'a> {
let mailboxes = self.user.list_mailboxes().await?;
let mut vmailboxes = BTreeMap::new();
for mb in mailboxes.iter() {
for (i, _) in mb.match_indices(MAILBOX_HIERARCHY_DELIMITER) {
for (i, _) in mb.match_indices(MBX_HIER_DELIM_RAW) {
if i > 0 {
let smb = &mb[..i];
vmailboxes.entry(smb).or_insert(false);
@ -159,29 +241,57 @@ impl<'a> AuthenticatedContext<'a> {
let mut ret = vec![];
for (mb, is_real) in vmailboxes.iter() {
if matches_wildcard(&wildcard, mb) {
let mailbox = mb
let mailbox: MailboxCodec = mb
.to_string()
.try_into()
.map_err(|_| anyhow!("invalid mailbox name"))?;
let mut items = vec![FlagNameAttribute::Extension(
"Subscribed".try_into().unwrap(),
)];
let mut items = vec![FlagNameAttribute::from(Atom::unvalidated("Subscribed"))];
// Decoration
if !*is_real {
items.push(FlagNameAttribute::Noselect);
} else {
match *mb {
"Drafts" => items.push(Atom::unvalidated("Drafts").into()),
"Archive" => items.push(Atom::unvalidated("Archive").into()),
"Sent" => items.push(Atom::unvalidated("Sent").into()),
"Trash" => items.push(Atom::unvalidated("Trash").into()),
_ => (),
};
}
// Result type
if is_lsub {
ret.push(Data::Lsub {
items,
delimiter: Some(MAILBOX_HIERARCHY_DELIMITER),
mailbox,
delimiter: Some(mbx_hier_delim),
mailbox: mailbox.clone(),
});
} else {
ret.push(Data::List {
items,
delimiter: Some(MAILBOX_HIERARCHY_DELIMITER),
mailbox,
delimiter: Some(mbx_hier_delim),
mailbox: mailbox.clone(),
});
}
// Also collect status
if let Some(sin) = status_item_names {
let ret_attrs = match self.status_items(mb, sin).await {
Ok(a) => a,
Err(e) => {
tracing::error!(err=?e, mailbox=%mb, "Unable to fetch status for mailbox");
continue;
}
};
let data = Data::Status {
mailbox,
items: ret_attrs.into(),
};
ret.push(data);
}
}
}
@ -190,79 +300,140 @@ impl<'a> AuthenticatedContext<'a> {
} else {
"LIST completed"
};
Ok((Response::ok(msg)?.with_body(ret), flow::Transition::None))
}
async fn status(
self,
mailbox: &MailboxCodec,
attributes: &[StatusAttribute],
) -> Result<(Response, flow::Transition)> {
let name = String::try_from(mailbox.clone())?;
let mb_opt = self.user.open_mailbox(&name).await?;
let mb = match mb_opt {
Some(mb) => mb,
None => {
return Ok((
Response::no("Mailbox does not exist")?,
flow::Transition::None,
))
}
};
let (view, _data) = MailboxView::new(mb).await?;
let mut ret_attrs = vec![];
for attr in attributes.iter() {
ret_attrs.push(match attr {
StatusAttribute::Messages => StatusAttributeValue::Messages(view.exists()?),
StatusAttribute::Unseen => StatusAttributeValue::Unseen(view.unseen_count() as u32),
StatusAttribute::Recent => StatusAttributeValue::Recent(view.recent()?),
StatusAttribute::UidNext => StatusAttributeValue::UidNext(view.uidnext()),
StatusAttribute::UidValidity => {
StatusAttributeValue::UidValidity(view.uidvalidity())
}
});
}
let data = vec![Body::Data(Data::Status {
mailbox: mailbox.clone(),
attributes: ret_attrs,
})];
Ok((
Response::ok("STATUS completed")?.with_body(data),
Response::build()
.to_req(self.req)
.message(msg)
.many_data(ret)
.ok()?,
flow::Transition::None,
))
}
async fn subscribe(self, mailbox: &MailboxCodec) -> Result<(Response, flow::Transition)> {
let name = String::try_from(mailbox.clone())?;
async fn status(
&mut self,
mailbox: &MailboxCodec<'static>,
attributes: &[StatusDataItemName],
) -> Result<(Response<'static>, flow::Transition)> {
let name: &str = MailboxName(mailbox).try_into()?;
let ret_attrs = match self.status_items(name, attributes).await {
Ok(v) => v,
Err(e) => match e.downcast_ref::<CommandError>() {
Some(CommandError::MailboxNotFound) => {
return Ok((
Response::build()
.to_req(self.req)
.message("Mailbox does not exist")
.no()?,
flow::Transition::None,
))
}
_ => return Err(e.into()),
},
};
let data = Data::Status {
mailbox: mailbox.clone(),
items: ret_attrs.into(),
};
Ok((
Response::build()
.to_req(self.req)
.message("STATUS completed")
.data(data)
.ok()?,
flow::Transition::None,
))
}
async fn status_items(
&mut self,
name: &str,
attributes: &[StatusDataItemName],
) -> Result<Vec<StatusDataItem>> {
let mb_opt = self.user.open_mailbox(name).await?;
let mb = match mb_opt {
Some(mb) => mb,
None => return Err(CommandError::MailboxNotFound.into()),
};
let view = MailboxView::new(mb, self.client_capabilities.condstore.is_enabled()).await;
let mut ret_attrs = vec![];
for attr in attributes.iter() {
ret_attrs.push(match attr {
StatusDataItemName::Messages => StatusDataItem::Messages(view.exists()?),
StatusDataItemName::Unseen => StatusDataItem::Unseen(view.unseen_count() as u32),
StatusDataItemName::Recent => StatusDataItem::Recent(view.recent()?),
StatusDataItemName::UidNext => StatusDataItem::UidNext(view.uidnext()),
StatusDataItemName::UidValidity => {
StatusDataItem::UidValidity(view.uidvalidity())
}
StatusDataItemName::Deleted => {
bail!("quota not implemented, can't return deleted elements waiting for EXPUNGE");
},
StatusDataItemName::DeletedStorage => {
bail!("quota not implemented, can't return freed storage after EXPUNGE will be run");
},
StatusDataItemName::HighestModSeq => {
self.client_capabilities.enable_condstore();
StatusDataItem::HighestModSeq(view.highestmodseq().get())
},
});
}
Ok(ret_attrs)
}
async fn subscribe(
self,
mailbox: &MailboxCodec<'a>,
) -> Result<(Response<'static>, flow::Transition)> {
let name: &str = MailboxName(mailbox).try_into()?;
if self.user.has_mailbox(&name).await? {
Ok((Response::ok("SUBSCRIBE complete")?, flow::Transition::None))
Ok((
Response::build()
.to_req(self.req)
.message("SUBSCRIBE complete")
.ok()?,
flow::Transition::None,
))
} else {
Ok((
Response::bad(&format!("Mailbox {} does not exist", name))?,
Response::build()
.to_req(self.req)
.message(format!("Mailbox {} does not exist", name))
.bad()?,
flow::Transition::None,
))
}
}
async fn unsubscribe(self, mailbox: &MailboxCodec) -> Result<(Response, flow::Transition)> {
let name = String::try_from(mailbox.clone())?;
async fn unsubscribe(
self,
mailbox: &MailboxCodec<'a>,
) -> Result<(Response<'static>, flow::Transition)> {
let name: &str = MailboxName(mailbox).try_into()?;
if self.user.has_mailbox(&name).await? {
Ok((
Response::bad(&format!(
"Cannot unsubscribe from mailbox {}: not supported by Aerogramme",
name
))?,
Response::build()
.to_req(self.req)
.message(format!(
"Cannot unsubscribe from mailbox {}: not supported by Aerogramme",
name
))
.bad()?,
flow::Transition::None,
))
} else {
Ok((
Response::bad(&format!("Mailbox {} does not exist", name))?,
Response::build()
.to_req(self.req)
.message(format!("Mailbox {} does not exist", name))
.no()?,
flow::Transition::None,
))
}
@ -289,6 +460,7 @@ impl<'a> AuthenticatedContext<'a> {
RFC9051 (imap4rev2) says that OK [UNSEEN] responses are deprecated after SELECT and EXAMINE
For Aerogramme, we just don't send the OK [UNSEEN], it's correct to do in both specifications.
20 select "INBOX.achats"
* FLAGS (\Answered \Flagged \Deleted \Seen \Draft $Forwarded JUNK $label1)
* OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft $Forwarded JUNK $label1 \*)] Flags permitted.
@ -301,102 +473,159 @@ impl<'a> AuthenticatedContext<'a> {
* TRACE END ---
*/
async fn select(self, mailbox: &MailboxCodec) -> Result<(Response, flow::Transition)> {
let name = String::try_from(mailbox.clone())?;
async fn select(
self,
mailbox: &MailboxCodec<'a>,
modifiers: &[SelectExamineModifier],
) -> Result<(Response<'static>, flow::Transition)> {
self.client_capabilities.select_enable(modifiers);
let name: &str = MailboxName(mailbox).try_into()?;
let mb_opt = self.user.open_mailbox(&name).await?;
let mb = match mb_opt {
Some(mb) => mb,
None => {
return Ok((
Response::no("Mailbox does not exist")?,
Response::build()
.to_req(self.req)
.message("Mailbox does not exist")
.no()?,
flow::Transition::None,
))
}
};
tracing::info!(username=%self.user.username, mailbox=%name, "mailbox.selected");
let (mb, data) = MailboxView::new(mb).await?;
let mb = MailboxView::new(mb, self.client_capabilities.condstore.is_enabled()).await;
let data = mb.summary()?;
Ok((
Response::ok("Select completed")?
.with_extra_code(Code::ReadWrite)
.with_body(data),
flow::Transition::Select(mb),
Response::build()
.message("Select completed")
.to_req(self.req)
.code(Code::ReadWrite)
.set_body(data)
.ok()?,
flow::Transition::Select(mb, flow::MailboxPerm::ReadWrite),
))
}
async fn examine(self, mailbox: &MailboxCodec) -> Result<(Response, flow::Transition)> {
let name = String::try_from(mailbox.clone())?;
async fn examine(
self,
mailbox: &MailboxCodec<'a>,
modifiers: &[SelectExamineModifier],
) -> Result<(Response<'static>, flow::Transition)> {
self.client_capabilities.select_enable(modifiers);
let name: &str = MailboxName(mailbox).try_into()?;
let mb_opt = self.user.open_mailbox(&name).await?;
let mb = match mb_opt {
Some(mb) => mb,
None => {
return Ok((
Response::no("Mailbox does not exist")?,
Response::build()
.to_req(self.req)
.message("Mailbox does not exist")
.no()?,
flow::Transition::None,
))
}
};
tracing::info!(username=%self.user.username, mailbox=%name, "mailbox.examined");
let (mb, data) = MailboxView::new(mb).await?;
let mb = MailboxView::new(mb, self.client_capabilities.condstore.is_enabled()).await;
let data = mb.summary()?;
Ok((
Response::ok("Examine completed")?
.with_extra_code(Code::ReadOnly)
.with_body(data),
flow::Transition::Examine(mb),
Response::build()
.to_req(self.req)
.message("Examine completed")
.code(Code::ReadOnly)
.set_body(data)
.ok()?,
flow::Transition::Select(mb, flow::MailboxPerm::ReadOnly),
))
}
//@FIXME we should write a specific version for the "selected" state
//that returns some unsollicited responses
async fn append(
self,
mailbox: &MailboxCodec,
flags: &[Flag],
date: &Option<MyDateTime>,
message: &NonZeroBytes,
) -> Result<(Response, flow::Transition)> {
mailbox: &MailboxCodec<'a>,
flags: &[Flag<'a>],
date: &Option<DateTime>,
message: &Literal<'a>,
) -> Result<(Response<'static>, flow::Transition)> {
let append_tag = self.req.tag.clone();
match self.append_internal(mailbox, flags, date, message).await {
Ok((_mb, uidvalidity, uid)) => Ok((
Response::ok("APPEND completed")?.with_extra_code(Code::Other(
"APPENDUID".try_into().unwrap(),
Some(format!("{} {}", uidvalidity, uid)),
)),
Ok((_mb_view, uidvalidity, uid, _modseq)) => Ok((
Response::build()
.tag(append_tag)
.message("APPEND completed")
.code(Code::Other(CodeOther::unvalidated(
format!("APPENDUID {} {}", uidvalidity, uid).into_bytes(),
)))
.ok()?,
flow::Transition::None,
)),
Err(e) => Ok((
Response::build()
.tag(append_tag)
.message(e.to_string())
.no()?,
flow::Transition::None,
)),
Err(e) => Ok((Response::no(&e.to_string())?, flow::Transition::None)),
}
}
fn enable(
self,
cap_enable: &Vec1<CapabilityEnable<'static>>,
) -> Result<(Response<'static>, flow::Transition)> {
let mut response_builder = Response::build().to_req(self.req);
let capabilities = self.client_capabilities.try_enable(cap_enable.as_ref());
if capabilities.len() > 0 {
response_builder = response_builder.data(Data::Enabled { capabilities });
}
Ok((
response_builder.message("ENABLE completed").ok()?,
flow::Transition::None,
))
}
//@FIXME should be refactored and integrated to the mailbox view
pub(crate) async fn append_internal(
self,
mailbox: &MailboxCodec,
flags: &[Flag],
date: &Option<MyDateTime>,
message: &NonZeroBytes,
) -> Result<(Arc<Mailbox>, ImapUidvalidity, ImapUidvalidity)> {
let name = String::try_from(mailbox.clone())?;
mailbox: &MailboxCodec<'a>,
flags: &[Flag<'a>],
date: &Option<DateTime>,
message: &Literal<'a>,
) -> Result<(MailboxView, ImapUidvalidity, ImapUid, ModSeq)> {
let name: &str = MailboxName(mailbox).try_into()?;
let mb_opt = self.user.open_mailbox(&name).await?;
let mb = match mb_opt {
Some(mb) => mb,
None => bail!("Mailbox does not exist"),
};
let mut view = MailboxView::new(mb, self.client_capabilities.condstore.is_enabled()).await;
if date.is_some() {
bail!("Cannot set date when appending message");
tracing::warn!("Cannot set date when appending message");
}
let msg = IMF::try_from(message.as_slice())
.map_err(|_| anyhow!("Could not parse e-mail message"))?;
let msg =
IMF::try_from(message.data()).map_err(|_| anyhow!("Could not parse e-mail message"))?;
let flags = flags.iter().map(|x| x.to_string()).collect::<Vec<_>>();
// TODO: filter allowed flags? ping @Quentin
let (uidvalidity, uid) = mb.append(msg, None, &flags[..]).await?;
let (uidvalidity, uid, modseq) =
view.internal.mailbox.append(msg, None, &flags[..]).await?;
//let unsollicited = view.update(UpdateParameters::default()).await?;
Ok((mb, uidvalidity, uid))
Ok((view, uidvalidity, uid, modseq))
}
}
@ -422,13 +651,19 @@ fn matches_wildcard(wildcard: &str, name: &str) -> bool {
&& j > 0
&& matches[i - 1][j]
&& (wildcard[j - 1] == '*'
|| (wildcard[j - 1] == '%' && name[i - 1] != MAILBOX_HIERARCHY_DELIMITER)));
|| (wildcard[j - 1] == '%' && name[i - 1] != MBX_HIER_DELIM_RAW)));
}
}
matches[name.len()][wildcard.len()]
}
#[derive(Error, Debug)]
pub enum CommandError {
#[error("Mailbox not found")]
MailboxNotFound,
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -1,128 +0,0 @@
use std::sync::Arc;
use anyhow::Result;
use boitalettres::proto::Request;
use boitalettres::proto::Response;
use imap_codec::types::command::{CommandBody, SearchKey};
use imap_codec::types::core::{Charset, NonZeroBytes};
use imap_codec::types::datetime::MyDateTime;
use imap_codec::types::fetch_attributes::MacroOrFetchAttributes;
use imap_codec::types::flag::Flag;
use imap_codec::types::mailbox::Mailbox as MailboxCodec;
use imap_codec::types::response::Code;
use imap_codec::types::sequence::SequenceSet;
use crate::imap::command::authenticated;
use crate::imap::flow;
use crate::imap::mailbox_view::MailboxView;
use crate::mail::user::User;
pub struct ExaminedContext<'a> {
pub req: &'a Request,
pub user: &'a Arc<User>,
pub mailbox: &'a mut MailboxView,
}
pub async fn dispatch(ctx: ExaminedContext<'_>) -> Result<(Response, flow::Transition)> {
match &ctx.req.command.body {
// CLOSE in examined state is not the same as in selected state
// (in selected state it also does an EXPUNGE, here it doesn't)
CommandBody::Close => ctx.close().await,
CommandBody::Fetch {
sequence_set,
attributes,
uid,
} => ctx.fetch(sequence_set, attributes, uid).await,
CommandBody::Search {
charset,
criteria,
uid,
} => ctx.search(charset, criteria, uid).await,
CommandBody::Noop => ctx.noop().await,
CommandBody::Append {
mailbox,
flags,
date,
message,
} => ctx.append(mailbox, flags, date, message).await,
_ => {
let ctx = authenticated::AuthenticatedContext {
req: ctx.req,
user: ctx.user,
};
authenticated::dispatch(ctx).await
}
}
}
// --- PRIVATE ---
impl<'a> ExaminedContext<'a> {
async fn close(self) -> Result<(Response, flow::Transition)> {
Ok((Response::ok("CLOSE completed")?, flow::Transition::Unselect))
}
pub async fn fetch(
self,
sequence_set: &SequenceSet,
attributes: &MacroOrFetchAttributes,
uid: &bool,
) -> Result<(Response, flow::Transition)> {
match self.mailbox.fetch(sequence_set, attributes, uid).await {
Ok(resp) => Ok((
Response::ok("FETCH completed")?.with_body(resp),
flow::Transition::None,
)),
Err(e) => Ok((Response::no(&e.to_string())?, flow::Transition::None)),
}
}
pub async fn search(
self,
_charset: &Option<Charset>,
_criteria: &SearchKey,
_uid: &bool,
) -> Result<(Response, flow::Transition)> {
Ok((Response::bad("Not implemented")?, flow::Transition::None))
}
pub async fn noop(self) -> Result<(Response, flow::Transition)> {
self.mailbox.mailbox.force_sync().await?;
let updates = self.mailbox.update().await?;
Ok((
Response::ok("NOOP completed.")?.with_body(updates),
flow::Transition::None,
))
}
async fn append(
self,
mailbox: &MailboxCodec,
flags: &[Flag],
date: &Option<MyDateTime>,
message: &NonZeroBytes,
) -> Result<(Response, flow::Transition)> {
let ctx2 = authenticated::AuthenticatedContext {
req: self.req,
user: self.user,
};
match ctx2.append_internal(mailbox, flags, date, message).await {
Ok((mb, uidvalidity, uid)) => {
let resp = Response::ok("APPEND completed")?.with_extra_code(Code::Other(
"APPENDUID".try_into().unwrap(),
Some(format!("{} {}", uidvalidity, uid)),
));
if Arc::ptr_eq(&mb, &self.mailbox.mailbox) {
let data = self.mailbox.update().await?;
Ok((resp.with_body(data), flow::Transition::None))
} else {
Ok((resp, flow::Transition::None))
}
}
Err(e) => Ok((Response::no(&e.to_string())?, flow::Transition::None)),
}
}
}

View file

@ -1,4 +1,20 @@
pub mod anonymous;
pub mod anystate;
pub mod authenticated;
pub mod examined;
pub mod selected;
use crate::mail::user::INBOX;
use imap_codec::imap_types::mailbox::Mailbox as MailboxCodec;
/// Convert an IMAP mailbox name/identifier representation
/// to an utf-8 string that is used internally in Aerogramme
struct MailboxName<'a>(&'a MailboxCodec<'a>);
impl<'a> TryInto<&'a str> for MailboxName<'a> {
type Error = std::str::Utf8Error;
fn try_into(self) -> Result<&'a str, Self::Error> {
match self.0 {
MailboxCodec::Inbox => Ok(INBOX),
MailboxCodec::Other(aname) => Ok(std::str::from_utf8(aname.as_ref())?),
}
}
}

View file

@ -1,51 +1,106 @@
use std::num::NonZeroU64;
use std::sync::Arc;
use anyhow::Result;
use boitalettres::proto::Request;
use boitalettres::proto::Response;
use imap_codec::types::command::CommandBody;
use imap_codec::types::flag::{Flag, StoreResponse, StoreType};
use imap_codec::types::mailbox::Mailbox as MailboxCodec;
use imap_codec::types::response::Code;
use imap_codec::types::sequence::SequenceSet;
use imap_codec::imap_types::command::{Command, CommandBody, FetchModifier, StoreModifier};
use imap_codec::imap_types::core::{Charset, Vec1};
use imap_codec::imap_types::fetch::MacroOrMessageDataItemNames;
use imap_codec::imap_types::flag::{Flag, StoreResponse, StoreType};
use imap_codec::imap_types::mailbox::Mailbox as MailboxCodec;
use imap_codec::imap_types::response::{Code, CodeOther};
use imap_codec::imap_types::search::SearchKey;
use imap_codec::imap_types::sequence::SequenceSet;
use crate::imap::command::examined;
use crate::imap::attributes::AttributesProxy;
use crate::imap::capability::{ClientCapability, ServerCapability};
use crate::imap::command::{anystate, authenticated, MailboxName};
use crate::imap::flow;
use crate::imap::mailbox_view::MailboxView;
use crate::imap::mailbox_view::{MailboxView, UpdateParameters};
use crate::imap::response::Response;
use crate::mail::user::User;
pub struct SelectedContext<'a> {
pub req: &'a Request,
pub req: &'a Command<'static>,
pub user: &'a Arc<User>,
pub mailbox: &'a mut MailboxView,
pub server_capabilities: &'a ServerCapability,
pub client_capabilities: &'a mut ClientCapability,
pub perm: &'a flow::MailboxPerm,
}
pub async fn dispatch(ctx: SelectedContext<'_>) -> Result<(Response, flow::Transition)> {
match &ctx.req.command.body {
// Only write commands here, read commands are handled in
// `examined.rs`
CommandBody::Close => ctx.close().await,
CommandBody::Expunge => ctx.expunge().await,
pub async fn dispatch<'a>(
ctx: SelectedContext<'a>,
) -> Result<(Response<'static>, flow::Transition)> {
match &ctx.req.body {
// Any State
// noop is specific to this state
CommandBody::Capability => {
anystate::capability(ctx.req.tag.clone(), ctx.server_capabilities)
}
CommandBody::Logout => anystate::logout(),
// Specific to this state (7 commands + NOOP)
CommandBody::Close => match ctx.perm {
flow::MailboxPerm::ReadWrite => ctx.close().await,
flow::MailboxPerm::ReadOnly => ctx.examine_close().await,
},
CommandBody::Noop | CommandBody::Check => ctx.noop().await,
CommandBody::Fetch {
sequence_set,
macro_or_item_names,
modifiers,
uid,
} => {
ctx.fetch(sequence_set, macro_or_item_names, modifiers, uid)
.await
}
//@FIXME SearchKey::And is a legacy hack, should be refactored
CommandBody::Search {
charset,
criteria,
uid,
} => {
ctx.search(charset, &SearchKey::And(criteria.clone()), uid)
.await
}
CommandBody::Expunge {
// UIDPLUS (rfc4315)
uid_sequence_set,
} => ctx.expunge(uid_sequence_set).await,
CommandBody::Store {
sequence_set,
kind,
response,
flags,
modifiers,
uid,
} => ctx.store(sequence_set, kind, response, flags, uid).await,
} => {
ctx.store(sequence_set, kind, response, flags, modifiers, uid)
.await
}
CommandBody::Copy {
sequence_set,
mailbox,
uid,
} => ctx.copy(sequence_set, mailbox, uid).await,
CommandBody::Move {
sequence_set,
mailbox,
uid,
} => ctx.r#move(sequence_set, mailbox, uid).await,
// UNSELECT extension (rfc3691)
CommandBody::Unselect => ctx.unselect().await,
// In selected mode, we fallback to authenticated when needed
_ => {
let ctx = examined::ExaminedContext {
authenticated::dispatch(authenticated::AuthenticatedContext {
req: ctx.req,
server_capabilities: ctx.server_capabilities,
client_capabilities: ctx.client_capabilities,
user: ctx.user,
mailbox: ctx.mailbox,
};
examined::dispatch(ctx).await
})
.await
}
}
}
@ -53,18 +108,136 @@ pub async fn dispatch(ctx: SelectedContext<'_>) -> Result<(Response, flow::Trans
// --- PRIVATE ---
impl<'a> SelectedContext<'a> {
async fn close(self) -> Result<(Response, flow::Transition)> {
async fn close(self) -> Result<(Response<'static>, flow::Transition)> {
// We expunge messages,
// but we don't send the untagged EXPUNGE responses
self.expunge().await?;
Ok((Response::ok("CLOSE completed")?, flow::Transition::Unselect))
let tag = self.req.tag.clone();
self.expunge(&None).await?;
Ok((
Response::build().tag(tag).message("CLOSE completed").ok()?,
flow::Transition::Unselect,
))
}
async fn expunge(self) -> Result<(Response, flow::Transition)> {
let data = self.mailbox.expunge().await?;
/// CLOSE in examined state is not the same as in selected state
/// (in selected state it also does an EXPUNGE, here it doesn't)
async fn examine_close(self) -> Result<(Response<'static>, flow::Transition)> {
Ok((
Response::build()
.to_req(self.req)
.message("CLOSE completed")
.ok()?,
flow::Transition::Unselect,
))
}
async fn unselect(self) -> Result<(Response<'static>, flow::Transition)> {
Ok((
Response::build()
.to_req(self.req)
.message("UNSELECT completed")
.ok()?,
flow::Transition::Unselect,
))
}
pub async fn fetch(
self,
sequence_set: &SequenceSet,
attributes: &'a MacroOrMessageDataItemNames<'static>,
modifiers: &[FetchModifier],
uid: &bool,
) -> Result<(Response<'static>, flow::Transition)> {
let ap = AttributesProxy::new(attributes, modifiers, *uid);
let mut changed_since: Option<NonZeroU64> = None;
modifiers.iter().for_each(|m| match m {
FetchModifier::ChangedSince(val) => {
changed_since = Some(*val);
}
});
match self
.mailbox
.fetch(sequence_set, &ap, changed_since, uid)
.await
{
Ok(resp) => {
// Capabilities enabling logic only on successful command
// (according to my understanding of the spec)
self.client_capabilities.attributes_enable(&ap);
self.client_capabilities.fetch_modifiers_enable(modifiers);
// Response to the client
Ok((
Response::build()
.to_req(self.req)
.message("FETCH completed")
.set_body(resp)
.ok()?,
flow::Transition::None,
))
}
Err(e) => Ok((
Response::build()
.to_req(self.req)
.message(e.to_string())
.no()?,
flow::Transition::None,
)),
}
}
pub async fn search(
self,
charset: &Option<Charset<'a>>,
criteria: &SearchKey<'a>,
uid: &bool,
) -> Result<(Response<'static>, flow::Transition)> {
let (found, enable_condstore) = self.mailbox.search(charset, criteria, *uid).await?;
if enable_condstore {
self.client_capabilities.enable_condstore();
}
Ok((
Response::build()
.to_req(self.req)
.set_body(found)
.message("SEARCH completed")
.ok()?,
flow::Transition::None,
))
}
pub async fn noop(self) -> Result<(Response<'static>, flow::Transition)> {
self.mailbox.internal.mailbox.force_sync().await?;
let updates = self.mailbox.update(UpdateParameters::default()).await?;
Ok((
Response::build()
.to_req(self.req)
.message("NOOP completed.")
.set_body(updates)
.ok()?,
flow::Transition::None,
))
}
async fn expunge(
self,
uid_sequence_set: &Option<SequenceSet>,
) -> Result<(Response<'static>, flow::Transition)> {
if let Some(failed) = self.fail_read_only() {
return Ok((failed, flow::Transition::None));
}
let tag = self.req.tag.clone();
let data = self.mailbox.expunge(uid_sequence_set).await?;
Ok((
Response::ok("EXPUNGE completed")?.with_body(data),
Response::build()
.tag(tag)
.message("EXPUNGE completed")
.set_body(data)
.ok()?,
flow::Transition::None,
))
}
@ -74,35 +247,76 @@ impl<'a> SelectedContext<'a> {
sequence_set: &SequenceSet,
kind: &StoreType,
response: &StoreResponse,
flags: &[Flag],
flags: &[Flag<'a>],
modifiers: &[StoreModifier],
uid: &bool,
) -> Result<(Response, flow::Transition)> {
let data = self
) -> Result<(Response<'static>, flow::Transition)> {
if let Some(failed) = self.fail_read_only() {
return Ok((failed, flow::Transition::None));
}
let mut unchanged_since: Option<NonZeroU64> = None;
modifiers.iter().for_each(|m| match m {
StoreModifier::UnchangedSince(val) => {
unchanged_since = Some(*val);
}
});
let (data, modified) = self
.mailbox
.store(sequence_set, kind, response, flags, uid)
.store(sequence_set, kind, response, flags, unchanged_since, uid)
.await?;
Ok((
Response::ok("STORE completed")?.with_body(data),
flow::Transition::None,
))
let mut ok_resp = Response::build()
.to_req(self.req)
.message("STORE completed")
.set_body(data);
match modified[..] {
[] => (),
[_head, ..] => {
let modified_str = format!(
"MODIFIED {}",
modified
.into_iter()
.map(|x| x.to_string())
.collect::<Vec<_>>()
.join(",")
);
ok_resp = ok_resp.code(Code::Other(CodeOther::unvalidated(
modified_str.into_bytes(),
)));
}
};
self.client_capabilities.store_modifiers_enable(modifiers);
Ok((ok_resp.ok()?, flow::Transition::None))
}
async fn copy(
self,
sequence_set: &SequenceSet,
mailbox: &MailboxCodec,
mailbox: &MailboxCodec<'a>,
uid: &bool,
) -> Result<(Response, flow::Transition)> {
let name = String::try_from(mailbox.clone())?;
) -> Result<(Response<'static>, flow::Transition)> {
//@FIXME Could copy be valid in EXAMINE mode?
if let Some(failed) = self.fail_read_only() {
return Ok((failed, flow::Transition::None));
}
let name: &str = MailboxName(mailbox).try_into()?;
let mb_opt = self.user.open_mailbox(&name).await?;
let mb = match mb_opt {
Some(mb) => mb,
None => {
return Ok((
Response::no("Destination mailbox does not exist")?
.with_extra_code(Code::TryCreate),
Response::build()
.to_req(self.req)
.message("Destination mailbox does not exist")
.code(Code::TryCreate)
.no()?,
flow::Transition::None,
))
}
@ -126,11 +340,85 @@ impl<'a> SelectedContext<'a> {
);
Ok((
Response::ok("COPY completed")?.with_extra_code(Code::Other(
"COPYUID".try_into().unwrap(),
Some(copyuid_str),
)),
Response::build()
.to_req(self.req)
.message("COPY completed")
.code(Code::Other(CodeOther::unvalidated(
format!("COPYUID {}", copyuid_str).into_bytes(),
)))
.ok()?,
flow::Transition::None,
))
}
async fn r#move(
self,
sequence_set: &SequenceSet,
mailbox: &MailboxCodec<'a>,
uid: &bool,
) -> Result<(Response<'static>, flow::Transition)> {
if let Some(failed) = self.fail_read_only() {
return Ok((failed, flow::Transition::None));
}
let name: &str = MailboxName(mailbox).try_into()?;
let mb_opt = self.user.open_mailbox(&name).await?;
let mb = match mb_opt {
Some(mb) => mb,
None => {
return Ok((
Response::build()
.to_req(self.req)
.message("Destination mailbox does not exist")
.code(Code::TryCreate)
.no()?,
flow::Transition::None,
))
}
};
let (uidval, uid_map, data) = self.mailbox.r#move(sequence_set, mb, uid).await?;
// compute code
let copyuid_str = format!(
"{} {} {}",
uidval,
uid_map
.iter()
.map(|(sid, _)| format!("{}", sid))
.collect::<Vec<_>>()
.join(","),
uid_map
.iter()
.map(|(_, tuid)| format!("{}", tuid))
.collect::<Vec<_>>()
.join(",")
);
Ok((
Response::build()
.to_req(self.req)
.message("COPY completed")
.code(Code::Other(CodeOther::unvalidated(
format!("COPYUID {}", copyuid_str).into_bytes(),
)))
.set_body(data)
.ok()?,
flow::Transition::None,
))
}
fn fail_read_only(&self) -> Option<Response<'static>> {
match self.perm {
flow::MailboxPerm::ReadWrite => None,
flow::MailboxPerm::ReadOnly => Some(
Response::build()
.to_req(self.req)
.message("Write command are forbidden while exmining mailbox")
.no()
.unwrap(),
),
}
}
}

30
src/imap/flags.rs Normal file
View file

@ -0,0 +1,30 @@
use imap_codec::imap_types::core::Atom;
use imap_codec::imap_types::flag::{Flag, FlagFetch};
pub fn from_str(f: &str) -> Option<FlagFetch<'static>> {
match f.chars().next() {
Some('\\') => match f {
"\\Seen" => Some(FlagFetch::Flag(Flag::Seen)),
"\\Answered" => Some(FlagFetch::Flag(Flag::Answered)),
"\\Flagged" => Some(FlagFetch::Flag(Flag::Flagged)),
"\\Deleted" => Some(FlagFetch::Flag(Flag::Deleted)),
"\\Draft" => Some(FlagFetch::Flag(Flag::Draft)),
"\\Recent" => Some(FlagFetch::Recent),
_ => match Atom::try_from(f.strip_prefix('\\').unwrap().to_string()) {
Err(_) => {
tracing::error!(flag=%f, "Unable to encode flag as IMAP atom");
None
}
Ok(a) => Some(FlagFetch::Flag(Flag::system(a))),
},
},
Some(_) => match Atom::try_from(f.to_string()) {
Err(_) => {
tracing::error!(flag=%f, "Unable to encode flag as IMAP atom");
None
}
Ok(a) => Some(FlagFetch::Flag(Flag::keyword(a))),
},
None => None,
}
}

View file

@ -2,6 +2,9 @@ use std::error::Error as StdError;
use std::fmt;
use std::sync::Arc;
use imap_codec::imap_types::core::Tag;
use tokio::sync::Notify;
use crate::imap::mailbox_view::MailboxView;
use crate::mail::user::User;
@ -19,41 +22,93 @@ impl StdError for Error {}
pub enum State {
NotAuthenticated,
Authenticated(Arc<User>),
Selected(Arc<User>, MailboxView),
// Examined is like Selected, but indicates that the mailbox is read-only
Examined(Arc<User>, MailboxView),
Selected(Arc<User>, MailboxView, MailboxPerm),
Idle(
Arc<User>,
MailboxView,
MailboxPerm,
Tag<'static>,
Arc<Notify>,
),
Logout,
}
impl State {
pub fn notify(&self) -> Option<Arc<Notify>> {
match self {
Self::Idle(_, _, _, _, anotif) => Some(anotif.clone()),
_ => None,
}
}
}
impl fmt::Display for State {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use State::*;
match self {
NotAuthenticated => write!(f, "NotAuthenticated"),
Authenticated(..) => write!(f, "Authenticated"),
Selected(..) => write!(f, "Selected"),
Idle(..) => write!(f, "Idle"),
Logout => write!(f, "Logout"),
}
}
}
#[derive(Clone)]
pub enum MailboxPerm {
ReadOnly,
ReadWrite,
}
pub enum Transition {
None,
Authenticate(Arc<User>),
Examine(MailboxView),
Select(MailboxView),
Select(MailboxView, MailboxPerm),
Idle(Tag<'static>, Notify),
UnIdle,
Unselect,
Logout,
}
impl fmt::Display for Transition {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use Transition::*;
match self {
None => write!(f, "None"),
Authenticate(..) => write!(f, "Authenticated"),
Select(..) => write!(f, "Selected"),
Idle(..) => write!(f, "Idle"),
UnIdle => write!(f, "UnIdle"),
Unselect => write!(f, "Unselect"),
Logout => write!(f, "Logout"),
}
}
}
// See RFC3501 section 3.
// https://datatracker.ietf.org/doc/html/rfc3501#page-13
impl State {
pub fn apply(self, tr: Transition) -> Result<Self, Error> {
match (self, tr) {
(s, Transition::None) => Ok(s),
(State::NotAuthenticated, Transition::Authenticate(u)) => Ok(State::Authenticated(u)),
(
State::Authenticated(u) | State::Selected(u, _) | State::Examined(u, _),
Transition::Select(m),
) => Ok(State::Selected(u, m)),
(
State::Authenticated(u) | State::Selected(u, _) | State::Examined(u, _),
Transition::Examine(m),
) => Ok(State::Examined(u, m)),
(State::Selected(u, _) | State::Examined(u, _), Transition::Unselect) => {
Ok(State::Authenticated(u))
pub fn apply(&mut self, tr: Transition) -> Result<(), Error> {
tracing::debug!(state=%self, transition=%tr, "try change state");
let new_state = match (std::mem::replace(self, State::Logout), tr) {
(s, Transition::None) => s,
(State::NotAuthenticated, Transition::Authenticate(u)) => State::Authenticated(u),
(State::Authenticated(u) | State::Selected(u, _, _), Transition::Select(m, p)) => {
State::Selected(u, m, p)
}
(_, Transition::Logout) => Ok(State::Logout),
_ => Err(Error::ForbiddenTransition),
}
(State::Selected(u, _, _), Transition::Unselect) => State::Authenticated(u.clone()),
(State::Selected(u, m, p), Transition::Idle(t, s)) => {
State::Idle(u, m, p, t, Arc::new(s))
}
(State::Idle(u, m, p, _, _), Transition::UnIdle) => State::Selected(u, m, p),
(_, Transition::Logout) => State::Logout,
(s, t) => {
tracing::error!(state=%s, transition=%t, "forbidden transition");
return Err(Error::ForbiddenTransition);
}
};
*self = new_state;
tracing::debug!(state=%self, "transition succeeded");
Ok(())
}
}

109
src/imap/imf_view.rs Normal file
View file

@ -0,0 +1,109 @@
use anyhow::{anyhow, Result};
use chrono::naive::NaiveDate;
use imap_codec::imap_types::core::{IString, NString};
use imap_codec::imap_types::envelope::{Address, Envelope};
use eml_codec::imf;
pub struct ImfView<'a>(pub &'a imf::Imf<'a>);
impl<'a> ImfView<'a> {
pub fn naive_date(&self) -> Result<NaiveDate> {
Ok(self.0.date.ok_or(anyhow!("date is not set"))?.date_naive())
}
/// Envelope rules are defined in RFC 3501, section 7.4.2
/// https://datatracker.ietf.org/doc/html/rfc3501#section-7.4.2
///
/// Some important notes:
///
/// If the Sender or Reply-To lines are absent in the [RFC-2822]
/// header, or are present but empty, the server sets the
/// corresponding member of the envelope to be the same value as
/// the from member (the client is not expected to know to do
/// this). Note: [RFC-2822] requires that all messages have a valid
/// From header. Therefore, the from, sender, and reply-to
/// members in the envelope can not be NIL.
///
/// If the Date, Subject, In-Reply-To, and Message-ID header lines
/// are absent in the [RFC-2822] header, the corresponding member
/// of the envelope is NIL; if these header lines are present but
/// empty the corresponding member of the envelope is the empty
/// string.
//@FIXME return an error if the envelope is invalid instead of panicking
//@FIXME some fields must be defaulted if there are not set.
pub fn message_envelope(&self) -> Envelope<'static> {
let msg = self.0;
let from = msg.from.iter().map(convert_mbx).collect::<Vec<_>>();
Envelope {
date: NString(
msg.date
.as_ref()
.map(|d| IString::try_from(d.to_rfc3339()).unwrap()),
),
subject: NString(
msg.subject
.as_ref()
.map(|d| IString::try_from(d.to_string()).unwrap()),
),
sender: msg
.sender
.as_ref()
.map(|v| vec![convert_mbx(v)])
.unwrap_or(from.clone()),
reply_to: if msg.reply_to.is_empty() {
from.clone()
} else {
convert_addresses(&msg.reply_to)
},
from,
to: convert_addresses(&msg.to),
cc: convert_addresses(&msg.cc),
bcc: convert_addresses(&msg.bcc),
in_reply_to: NString(
msg.in_reply_to
.iter()
.next()
.map(|d| IString::try_from(d.to_string()).unwrap()),
),
message_id: NString(
msg.msg_id
.as_ref()
.map(|d| IString::try_from(d.to_string()).unwrap()),
),
}
}
}
pub fn convert_addresses(addrlist: &Vec<imf::address::AddressRef>) -> Vec<Address<'static>> {
let mut acc = vec![];
for item in addrlist {
match item {
imf::address::AddressRef::Single(a) => acc.push(convert_mbx(a)),
imf::address::AddressRef::Many(l) => acc.extend(l.participants.iter().map(convert_mbx)),
}
}
return acc;
}
pub fn convert_mbx(addr: &imf::mailbox::MailboxRef) -> Address<'static> {
Address {
name: NString(
addr.name
.as_ref()
.map(|x| IString::try_from(x.to_string()).unwrap()),
),
// SMTP at-domain-list (source route) seems obsolete since at least 1991
// https://www.mhonarc.org/archive/html/ietf-822/1991-06/msg00060.html
adl: NString(None),
mailbox: NString(Some(
IString::try_from(addr.addrspec.local_part.to_string()).unwrap(),
)),
host: NString(Some(
IString::try_from(addr.addrspec.domain.to_string()).unwrap(),
)),
}
}

211
src/imap/index.rs Normal file
View file

@ -0,0 +1,211 @@
use std::num::{NonZeroU32, NonZeroU64};
use anyhow::{anyhow, Result};
use imap_codec::imap_types::sequence::{SeqOrUid, Sequence, SequenceSet};
use crate::mail::uidindex::{ImapUid, ModSeq, UidIndex};
use crate::mail::unique_ident::UniqueIdent;
pub struct Index<'a> {
pub imap_index: Vec<MailIndex<'a>>,
pub internal: &'a UidIndex,
}
impl<'a> Index<'a> {
pub fn new(internal: &'a UidIndex) -> Result<Self> {
let imap_index = internal
.idx_by_uid
.iter()
.enumerate()
.map(|(i_enum, (&uid, &uuid))| {
let (_, modseq, flags) = internal
.table
.get(&uuid)
.ok_or(anyhow!("mail is missing from index"))?;
let i_int: u32 = (i_enum + 1).try_into()?;
let i: NonZeroU32 = i_int.try_into()?;
Ok(MailIndex {
i,
uid,
uuid,
modseq: *modseq,
flags,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(Self {
imap_index,
internal,
})
}
pub fn last(&'a self) -> Option<&'a MailIndex<'a>> {
self.imap_index.last()
}
/// Fetch mail descriptors based on a sequence of UID
///
/// Complexity analysis:
/// - Sort is O(n * log n) where n is the number of uid generated by the sequence
/// - Finding the starting point in the index O(log m) where m is the size of the mailbox
/// While n =< m, it's not clear if the difference is big or not.
///
/// For now, the algorithm tries to be fast for small values of n,
/// as it is what is expected by clients.
///
/// So we assume for our implementation that : n << m.
/// It's not true for full mailbox searches for example...
pub fn fetch_on_uid(&'a self, sequence_set: &SequenceSet) -> Vec<&'a MailIndex<'a>> {
if self.imap_index.is_empty() {
return vec![];
}
let largest = self.last().expect("The mailbox is not empty").uid;
let mut unroll_seq = sequence_set.iter(largest).collect::<Vec<_>>();
unroll_seq.sort();
let start_seq = match unroll_seq.iter().next() {
Some(elem) => elem,
None => return vec![],
};
// Quickly jump to the right point in the mailbox vector O(log m) instead
// of iterating one by one O(m). Works only because both unroll_seq & imap_index are sorted per uid.
let mut imap_idx = {
let start_idx = self
.imap_index
.partition_point(|mail_idx| &mail_idx.uid < start_seq);
&self.imap_index[start_idx..]
};
let mut acc = vec![];
for wanted_uid in unroll_seq.iter() {
// Slide the window forward as long as its first element is lower than our wanted uid.
let start_idx = match imap_idx.iter().position(|midx| &midx.uid >= wanted_uid) {
Some(v) => v,
None => break,
};
imap_idx = &imap_idx[start_idx..];
// If the beginning of our new window is the uid we want, we collect it
if &imap_idx[0].uid == wanted_uid {
acc.push(&imap_idx[0]);
}
}
acc
}
pub fn fetch_on_id(&'a self, sequence_set: &SequenceSet) -> Result<Vec<&'a MailIndex<'a>>> {
if self.imap_index.is_empty() {
return Ok(vec![]);
}
let largest = NonZeroU32::try_from(self.imap_index.len() as u32)?;
let mut acc = sequence_set
.iter(largest)
.map(|wanted_id| {
self.imap_index
.get((wanted_id.get() as usize) - 1)
.ok_or(anyhow!("Mail not found"))
})
.collect::<Result<Vec<_>>>()?;
// Sort the result to be consistent with UID
acc.sort_by(|a, b| a.i.cmp(&b.i));
Ok(acc)
}
pub fn fetch(
self: &'a Index<'a>,
sequence_set: &SequenceSet,
by_uid: bool,
) -> Result<Vec<&'a MailIndex<'a>>> {
match by_uid {
true => Ok(self.fetch_on_uid(sequence_set)),
_ => self.fetch_on_id(sequence_set),
}
}
pub fn fetch_changed_since(
self: &'a Index<'a>,
sequence_set: &SequenceSet,
maybe_modseq: Option<NonZeroU64>,
by_uid: bool,
) -> Result<Vec<&'a MailIndex<'a>>> {
let raw = self.fetch(sequence_set, by_uid)?;
let res = match maybe_modseq {
Some(pit) => raw.into_iter().filter(|midx| midx.modseq > pit).collect(),
None => raw,
};
Ok(res)
}
pub fn fetch_unchanged_since(
self: &'a Index<'a>,
sequence_set: &SequenceSet,
maybe_modseq: Option<NonZeroU64>,
by_uid: bool,
) -> Result<(Vec<&'a MailIndex<'a>>, Vec<&'a MailIndex<'a>>)> {
let raw = self.fetch(sequence_set, by_uid)?;
let res = match maybe_modseq {
Some(pit) => raw.into_iter().partition(|midx| midx.modseq <= pit),
None => (raw, vec![]),
};
Ok(res)
}
}
#[derive(Clone, Debug)]
pub struct MailIndex<'a> {
pub i: NonZeroU32,
pub uid: ImapUid,
pub uuid: UniqueIdent,
pub modseq: ModSeq,
pub flags: &'a Vec<String>,
}
impl<'a> MailIndex<'a> {
// The following functions are used to implement the SEARCH command
pub fn is_in_sequence_i(&self, seq: &Sequence) -> bool {
match seq {
Sequence::Single(SeqOrUid::Asterisk) => true,
Sequence::Single(SeqOrUid::Value(target)) => target == &self.i,
Sequence::Range(SeqOrUid::Asterisk, SeqOrUid::Value(x))
| Sequence::Range(SeqOrUid::Value(x), SeqOrUid::Asterisk) => x <= &self.i,
Sequence::Range(SeqOrUid::Value(x1), SeqOrUid::Value(x2)) => {
if x1 < x2 {
x1 <= &self.i && &self.i <= x2
} else {
x1 >= &self.i && &self.i >= x2
}
}
Sequence::Range(SeqOrUid::Asterisk, SeqOrUid::Asterisk) => true,
}
}
pub fn is_in_sequence_uid(&self, seq: &Sequence) -> bool {
match seq {
Sequence::Single(SeqOrUid::Asterisk) => true,
Sequence::Single(SeqOrUid::Value(target)) => target == &self.uid,
Sequence::Range(SeqOrUid::Asterisk, SeqOrUid::Value(x))
| Sequence::Range(SeqOrUid::Value(x), SeqOrUid::Asterisk) => x <= &self.uid,
Sequence::Range(SeqOrUid::Value(x1), SeqOrUid::Value(x2)) => {
if x1 < x2 {
x1 <= &self.uid && &self.uid <= x2
} else {
x1 >= &self.uid && &self.uid >= x2
}
}
Sequence::Range(SeqOrUid::Asterisk, SeqOrUid::Asterisk) => true,
}
}
pub fn is_flag_set(&self, flag: &str) -> bool {
self.flags
.iter()
.any(|candidate| candidate.as_str() == flag)
}
}

306
src/imap/mail_view.rs Normal file
View file

@ -0,0 +1,306 @@
use std::num::NonZeroU32;
use anyhow::{anyhow, bail, Result};
use chrono::{naive::NaiveDate, DateTime as ChronoDateTime, Local, Offset, TimeZone, Utc};
use imap_codec::imap_types::core::NString;
use imap_codec::imap_types::datetime::DateTime;
use imap_codec::imap_types::fetch::{
MessageDataItem, MessageDataItemName, Section as FetchSection,
};
use imap_codec::imap_types::flag::Flag;
use imap_codec::imap_types::response::Data;
use eml_codec::{
imf,
part::{composite::Message, AnyPart},
};
use crate::mail::query::QueryResult;
use crate::imap::attributes::AttributesProxy;
use crate::imap::flags;
use crate::imap::imf_view::ImfView;
use crate::imap::index::MailIndex;
use crate::imap::mime_view;
use crate::imap::response::Body;
pub struct MailView<'a> {
pub in_idx: &'a MailIndex<'a>,
pub query_result: &'a QueryResult,
pub content: FetchedMail<'a>,
}
impl<'a> MailView<'a> {
pub fn new(query_result: &'a QueryResult, in_idx: &'a MailIndex<'a>) -> Result<MailView<'a>> {
Ok(Self {
in_idx,
query_result,
content: match query_result {
QueryResult::FullResult { content, .. } => {
let (_, parsed) =
eml_codec::parse_message(&content).or(Err(anyhow!("Invalid mail body")))?;
FetchedMail::full_from_message(parsed)
}
QueryResult::PartialResult { metadata, .. } => {
let (_, parsed) = eml_codec::parse_message(&metadata.headers)
.or(Err(anyhow!("unable to parse email headers")))?;
FetchedMail::partial_from_message(parsed)
}
QueryResult::IndexResult { .. } => FetchedMail::IndexOnly,
},
})
}
pub fn imf(&self) -> Option<ImfView> {
self.content.as_imf().map(ImfView)
}
pub fn selected_mime(&'a self) -> Option<mime_view::SelectedMime<'a>> {
self.content.as_anypart().ok().map(mime_view::SelectedMime)
}
pub fn filter(&self, ap: &AttributesProxy) -> Result<(Body<'static>, SeenFlag)> {
let mut seen = SeenFlag::DoNothing;
let res_attrs = ap
.attrs
.iter()
.map(|attr| match attr {
MessageDataItemName::Uid => Ok(self.uid()),
MessageDataItemName::Flags => Ok(self.flags()),
MessageDataItemName::Rfc822Size => self.rfc_822_size(),
MessageDataItemName::Rfc822Header => self.rfc_822_header(),
MessageDataItemName::Rfc822Text => self.rfc_822_text(),
MessageDataItemName::Rfc822 => {
if self.is_not_yet_seen() {
seen = SeenFlag::MustAdd;
}
self.rfc822()
}
MessageDataItemName::Envelope => Ok(self.envelope()),
MessageDataItemName::Body => self.body(),
MessageDataItemName::BodyStructure => self.body_structure(),
MessageDataItemName::BodyExt {
section,
partial,
peek,
} => {
let (body, has_seen) = self.body_ext(section, partial, peek)?;
seen = has_seen;
Ok(body)
}
MessageDataItemName::InternalDate => self.internal_date(),
MessageDataItemName::ModSeq => Ok(self.modseq()),
})
.collect::<Result<Vec<_>, _>>()?;
Ok((
Body::Data(Data::Fetch {
seq: self.in_idx.i,
items: res_attrs.try_into()?,
}),
seen,
))
}
pub fn stored_naive_date(&self) -> Result<NaiveDate> {
let mail_meta = self.query_result.metadata().expect("metadata were fetched");
let mail_ts: i64 = mail_meta.internaldate.try_into()?;
let msg_date: ChronoDateTime<Local> = ChronoDateTime::from_timestamp(mail_ts, 0)
.ok_or(anyhow!("unable to parse timestamp"))?
.with_timezone(&Local);
Ok(msg_date.date_naive())
}
pub fn is_header_contains_pattern(&self, hdr: &[u8], pattern: &[u8]) -> bool {
let mime = match self.selected_mime() {
None => return false,
Some(x) => x,
};
let val = match mime.header_value(hdr) {
None => return false,
Some(x) => x,
};
val.windows(pattern.len()).any(|win| win == pattern)
}
// Private function, mainly for filter!
fn uid(&self) -> MessageDataItem<'static> {
MessageDataItem::Uid(self.in_idx.uid.clone())
}
fn flags(&self) -> MessageDataItem<'static> {
MessageDataItem::Flags(
self.in_idx
.flags
.iter()
.filter_map(|f| flags::from_str(f))
.collect(),
)
}
fn rfc_822_size(&self) -> Result<MessageDataItem<'static>> {
let sz = self
.query_result
.metadata()
.ok_or(anyhow!("mail metadata are required"))?
.rfc822_size;
Ok(MessageDataItem::Rfc822Size(sz as u32))
}
fn rfc_822_header(&self) -> Result<MessageDataItem<'static>> {
let hdrs: NString = self
.query_result
.metadata()
.ok_or(anyhow!("mail metadata are required"))?
.headers
.to_vec()
.try_into()?;
Ok(MessageDataItem::Rfc822Header(hdrs))
}
fn rfc_822_text(&self) -> Result<MessageDataItem<'static>> {
let txt: NString = self.content.as_msg()?.raw_body.to_vec().try_into()?;
Ok(MessageDataItem::Rfc822Text(txt))
}
fn rfc822(&self) -> Result<MessageDataItem<'static>> {
let full: NString = self.content.as_msg()?.raw_part.to_vec().try_into()?;
Ok(MessageDataItem::Rfc822(full))
}
fn envelope(&self) -> MessageDataItem<'static> {
MessageDataItem::Envelope(
self.imf()
.expect("an imf object is derivable from fetchedmail")
.message_envelope(),
)
}
fn body(&self) -> Result<MessageDataItem<'static>> {
Ok(MessageDataItem::Body(mime_view::bodystructure(
self.content.as_msg()?.child.as_ref(),
false,
)?))
}
fn body_structure(&self) -> Result<MessageDataItem<'static>> {
Ok(MessageDataItem::BodyStructure(mime_view::bodystructure(
self.content.as_msg()?.child.as_ref(),
true,
)?))
}
fn is_not_yet_seen(&self) -> bool {
let seen_flag = Flag::Seen.to_string();
!self.in_idx.flags.iter().any(|x| *x == seen_flag)
}
/// maps to BODY[<section>]<<partial>> and BODY.PEEK[<section>]<<partial>>
/// peek does not implicitly set the \Seen flag
/// eg. BODY[HEADER.FIELDS (DATE FROM)]
/// eg. BODY[]<0.2048>
fn body_ext(
&self,
section: &Option<FetchSection<'static>>,
partial: &Option<(u32, NonZeroU32)>,
peek: &bool,
) -> Result<(MessageDataItem<'static>, SeenFlag)> {
// Manage Seen flag
let mut seen = SeenFlag::DoNothing;
if !peek && self.is_not_yet_seen() {
// Add \Seen flag
//self.mailbox.add_flags(uuid, &[seen_flag]).await?;
seen = SeenFlag::MustAdd;
}
// Process message
let (text, origin) =
match mime_view::body_ext(self.content.as_anypart()?, section, partial)? {
mime_view::BodySection::Full(body) => (body, None),
mime_view::BodySection::Slice { body, origin_octet } => (body, Some(origin_octet)),
};
let data: NString = text.to_vec().try_into()?;
return Ok((
MessageDataItem::BodyExt {
section: section.as_ref().map(|fs| fs.clone()),
origin,
data,
},
seen,
));
}
fn internal_date(&self) -> Result<MessageDataItem<'static>> {
let dt = Utc
.fix()
.timestamp_opt(
i64::try_from(
self.query_result
.metadata()
.ok_or(anyhow!("mail metadata were not fetched"))?
.internaldate
/ 1000,
)?,
0,
)
.earliest()
.ok_or(anyhow!("Unable to parse internal date"))?;
Ok(MessageDataItem::InternalDate(DateTime::unvalidated(dt)))
}
fn modseq(&self) -> MessageDataItem<'static> {
MessageDataItem::ModSeq(self.in_idx.modseq)
}
}
pub enum SeenFlag {
DoNothing,
MustAdd,
}
// -------------------
pub enum FetchedMail<'a> {
IndexOnly,
Partial(AnyPart<'a>),
Full(AnyPart<'a>),
}
impl<'a> FetchedMail<'a> {
pub fn full_from_message(msg: Message<'a>) -> Self {
Self::Full(AnyPart::Msg(msg))
}
pub fn partial_from_message(msg: Message<'a>) -> Self {
Self::Partial(AnyPart::Msg(msg))
}
pub fn as_anypart(&self) -> Result<&AnyPart<'a>> {
match self {
FetchedMail::Full(x) => Ok(&x),
FetchedMail::Partial(x) => Ok(&x),
_ => bail!("The full message must be fetched, not only its headers"),
}
}
pub fn as_msg(&self) -> Result<&Message<'a>> {
match self {
FetchedMail::Full(AnyPart::Msg(x)) => Ok(&x),
FetchedMail::Partial(AnyPart::Msg(x)) => Ok(&x),
_ => bail!("The full message must be fetched, not only its headers AND it must be an AnyPart::Msg."),
}
}
pub fn as_imf(&self) -> Option<&imf::Imf<'a>> {
match self {
FetchedMail::Full(AnyPart::Msg(x)) => Some(&x.imf),
FetchedMail::Partial(AnyPart::Msg(x)) => Some(&x.imf),
_ => None,
}
}
}

File diff suppressed because it is too large Load diff

580
src/imap/mime_view.rs Normal file
View file

@ -0,0 +1,580 @@
use std::borrow::Cow;
use std::collections::HashSet;
use std::num::NonZeroU32;
use anyhow::{anyhow, bail, Result};
use imap_codec::imap_types::body::{
BasicFields, Body as FetchBody, BodyStructure, MultiPartExtensionData, SinglePartExtensionData,
SpecificFields,
};
use imap_codec::imap_types::core::{AString, IString, NString, Vec1};
use imap_codec::imap_types::fetch::{Part as FetchPart, Section as FetchSection};
use eml_codec::{
header, mime, mime::r#type::Deductible, part::composite, part::discrete, part::AnyPart,
};
use crate::imap::imf_view::ImfView;
pub enum BodySection<'a> {
Full(Cow<'a, [u8]>),
Slice {
body: Cow<'a, [u8]>,
origin_octet: u32,
},
}
/// Logic for BODY[<section>]<<partial>>
/// Works in 3 times:
/// 1. Find the section (RootMime::subset)
/// 2. Apply the extraction logic (SelectedMime::extract), like TEXT, HEADERS, etc.
/// 3. Keep only the given subset provided by partial
///
/// Example of message sections:
///
/// ```
/// HEADER ([RFC-2822] header of the message)
/// TEXT ([RFC-2822] text body of the message) MULTIPART/MIXED
/// 1 TEXT/PLAIN
/// 2 APPLICATION/OCTET-STREAM
/// 3 MESSAGE/RFC822
/// 3.HEADER ([RFC-2822] header of the message)
/// 3.TEXT ([RFC-2822] text body of the message) MULTIPART/MIXED
/// 3.1 TEXT/PLAIN
/// 3.2 APPLICATION/OCTET-STREAM
/// 4 MULTIPART/MIXED
/// 4.1 IMAGE/GIF
/// 4.1.MIME ([MIME-IMB] header for the IMAGE/GIF)
/// 4.2 MESSAGE/RFC822
/// 4.2.HEADER ([RFC-2822] header of the message)
/// 4.2.TEXT ([RFC-2822] text body of the message) MULTIPART/MIXED
/// 4.2.1 TEXT/PLAIN
/// 4.2.2 MULTIPART/ALTERNATIVE
/// 4.2.2.1 TEXT/PLAIN
/// 4.2.2.2 TEXT/RICHTEXT
/// ```
pub fn body_ext<'a>(
part: &'a AnyPart<'a>,
section: &'a Option<FetchSection<'a>>,
partial: &'a Option<(u32, NonZeroU32)>,
) -> Result<BodySection<'a>> {
let root_mime = NodeMime(part);
let (extractor, path) = SubsettedSection::from(section);
let selected_mime = root_mime.subset(path)?;
let extracted_full = selected_mime.extract(&extractor)?;
Ok(extracted_full.to_body_section(partial))
}
/// Logic for BODY and BODYSTRUCTURE
///
/// ```raw
/// b fetch 29878:29879 (BODY)
/// * 29878 FETCH (BODY (("text" "plain" ("charset" "utf-8") NIL NIL "quoted-printable" 3264 82)("text" "html" ("charset" "utf-8") NIL NIL "quoted-printable" 31834 643) "alternative"))
/// * 29879 FETCH (BODY ("text" "html" ("charset" "us-ascii") NIL NIL "7bit" 4107 131))
/// ^^^^^^^^^^^^^^^^^^^^^^ ^^^ ^^^ ^^^^^^ ^^^^ ^^^
/// | | | | | | number of lines
/// | | | | | size
/// | | | | content transfer encoding
/// | | | description
/// | | id
/// | parameter list
/// b OK Fetch completed (0.001 + 0.000 secs).
/// ```
pub fn bodystructure(part: &AnyPart, is_ext: bool) -> Result<BodyStructure<'static>> {
NodeMime(part).structure(is_ext)
}
/// NodeMime
///
/// Used for recursive logic on MIME.
/// See SelectedMime for inspection.
struct NodeMime<'a>(&'a AnyPart<'a>);
impl<'a> NodeMime<'a> {
/// A MIME object is a tree of elements.
/// The path indicates which element must be picked.
/// This function returns the picked element as the new view
fn subset(self, path: Option<&'a FetchPart>) -> Result<SelectedMime<'a>> {
match path {
None => Ok(SelectedMime(self.0)),
Some(v) => self.rec_subset(v.0.as_ref()),
}
}
fn rec_subset(self, path: &'a [NonZeroU32]) -> Result<SelectedMime> {
if path.is_empty() {
Ok(SelectedMime(self.0))
} else {
match self.0 {
AnyPart::Mult(x) => {
let next = Self(x.children
.get(path[0].get() as usize - 1)
.ok_or(anyhow!("Unable to resolve subpath {:?}, current multipart has only {} elements", path, x.children.len()))?);
next.rec_subset(&path[1..])
},
AnyPart::Msg(x) => {
let next = Self(x.child.as_ref());
next.rec_subset(path)
},
_ => bail!("You tried to access a subpart on an atomic part (text or binary). Unresolved subpath {:?}", path),
}
}
}
fn structure(&self, is_ext: bool) -> Result<BodyStructure<'static>> {
match self.0 {
AnyPart::Txt(x) => NodeTxt(self, x).structure(is_ext),
AnyPart::Bin(x) => NodeBin(self, x).structure(is_ext),
AnyPart::Mult(x) => NodeMult(self, x).structure(is_ext),
AnyPart::Msg(x) => NodeMsg(self, x).structure(is_ext),
}
}
}
//----------------------------------------------------------
/// A FetchSection must be handled in 2 times:
/// - First we must extract the MIME part
/// - Then we must process it as desired
/// The given struct mixes both work, so
/// we separate this work here.
enum SubsettedSection<'a> {
Part,
Header,
HeaderFields(&'a Vec1<AString<'a>>),
HeaderFieldsNot(&'a Vec1<AString<'a>>),
Text,
Mime,
}
impl<'a> SubsettedSection<'a> {
fn from(section: &'a Option<FetchSection>) -> (Self, Option<&'a FetchPart>) {
match section {
Some(FetchSection::Text(maybe_part)) => (Self::Text, maybe_part.as_ref()),
Some(FetchSection::Header(maybe_part)) => (Self::Header, maybe_part.as_ref()),
Some(FetchSection::HeaderFields(maybe_part, fields)) => {
(Self::HeaderFields(fields), maybe_part.as_ref())
}
Some(FetchSection::HeaderFieldsNot(maybe_part, fields)) => {
(Self::HeaderFieldsNot(fields), maybe_part.as_ref())
}
Some(FetchSection::Mime(part)) => (Self::Mime, Some(part)),
Some(FetchSection::Part(part)) => (Self::Part, Some(part)),
None => (Self::Part, None),
}
}
}
/// Used for current MIME inspection
///
/// See NodeMime for recursive logic
pub struct SelectedMime<'a>(pub &'a AnyPart<'a>);
impl<'a> SelectedMime<'a> {
pub fn header_value(&'a self, to_match_ext: &[u8]) -> Option<&'a [u8]> {
let to_match = to_match_ext.to_ascii_lowercase();
self.eml_mime()
.kv
.iter()
.filter_map(|field| match field {
header::Field::Good(header::Kv2(k, v)) => Some((k, v)),
_ => None,
})
.find(|(k, _)| k.to_ascii_lowercase() == to_match)
.map(|(_, v)| v)
.copied()
}
/// The subsetted fetch section basically tells us the
/// extraction logic to apply on our selected MIME.
/// This function acts as a router for these logic.
fn extract(&self, extractor: &SubsettedSection<'a>) -> Result<ExtractedFull<'a>> {
match extractor {
SubsettedSection::Text => self.text(),
SubsettedSection::Header => self.header(),
SubsettedSection::HeaderFields(fields) => self.header_fields(fields, false),
SubsettedSection::HeaderFieldsNot(fields) => self.header_fields(fields, true),
SubsettedSection::Part => self.part(),
SubsettedSection::Mime => self.mime(),
}
}
fn mime(&self) -> Result<ExtractedFull<'a>> {
let bytes = match &self.0 {
AnyPart::Txt(p) => p.mime.fields.raw,
AnyPart::Bin(p) => p.mime.fields.raw,
AnyPart::Msg(p) => p.child.mime().raw,
AnyPart::Mult(p) => p.mime.fields.raw,
};
Ok(ExtractedFull(bytes.into()))
}
fn part(&self) -> Result<ExtractedFull<'a>> {
let bytes = match &self.0 {
AnyPart::Txt(p) => p.body,
AnyPart::Bin(p) => p.body,
AnyPart::Msg(p) => p.raw_part,
AnyPart::Mult(_) => bail!("Multipart part has no body"),
};
Ok(ExtractedFull(bytes.to_vec().into()))
}
fn eml_mime(&self) -> &eml_codec::mime::NaiveMIME<'_> {
match &self.0 {
AnyPart::Msg(msg) => msg.child.mime(),
other => other.mime(),
}
}
/// The [...] HEADER.FIELDS, and HEADER.FIELDS.NOT part
/// specifiers refer to the [RFC-2822] header of the message or of
/// an encapsulated [MIME-IMT] MESSAGE/RFC822 message.
/// HEADER.FIELDS and HEADER.FIELDS.NOT are followed by a list of
/// field-name (as defined in [RFC-2822]) names, and return a
/// subset of the header. The subset returned by HEADER.FIELDS
/// contains only those header fields with a field-name that
/// matches one of the names in the list; similarly, the subset
/// returned by HEADER.FIELDS.NOT contains only the header fields
/// with a non-matching field-name. The field-matching is
/// case-insensitive but otherwise exact.
fn header_fields(
&self,
fields: &'a Vec1<AString<'a>>,
invert: bool,
) -> Result<ExtractedFull<'a>> {
// Build a lowercase ascii hashset with the fields to fetch
let index = fields
.as_ref()
.iter()
.map(|x| {
match x {
AString::Atom(a) => a.inner().as_bytes(),
AString::String(IString::Literal(l)) => l.as_ref(),
AString::String(IString::Quoted(q)) => q.inner().as_bytes(),
}
.to_ascii_lowercase()
})
.collect::<HashSet<_>>();
// Extract MIME headers
let mime = self.eml_mime();
// Filter our MIME headers based on the field index
// 1. Keep only the correctly formatted headers
// 2. Keep only based on the index presence or absence
// 3. Reduce as a byte vector
let buffer = mime
.kv
.iter()
.filter_map(|field| match field {
header::Field::Good(header::Kv2(k, v)) => Some((k, v)),
_ => None,
})
.filter(|(k, _)| index.contains(&k.to_ascii_lowercase()) ^ invert)
.fold(vec![], |mut acc, (k, v)| {
acc.extend(*k);
acc.extend(b": ");
acc.extend(*v);
acc.extend(b"\r\n");
acc
});
Ok(ExtractedFull(buffer.into()))
}
/// The HEADER [...] part specifiers refer to the [RFC-2822] header of the message or of
/// an encapsulated [MIME-IMT] MESSAGE/RFC822 message.
/// ```raw
/// HEADER ([RFC-2822] header of the message)
/// ```
fn header(&self) -> Result<ExtractedFull<'a>> {
let msg = self
.0
.as_message()
.ok_or(anyhow!("Selected part must be a message/rfc822"))?;
Ok(ExtractedFull(msg.raw_headers.into()))
}
/// The TEXT part specifier refers to the text body of the message, omitting the [RFC-2822] header.
fn text(&self) -> Result<ExtractedFull<'a>> {
let msg = self
.0
.as_message()
.ok_or(anyhow!("Selected part must be a message/rfc822"))?;
Ok(ExtractedFull(msg.raw_body.into()))
}
// ------------
/// Basic field of a MIME part that is
/// common to all parts
fn basic_fields(&self) -> Result<BasicFields<'static>> {
let sz = match self.0 {
AnyPart::Txt(x) => x.body.len(),
AnyPart::Bin(x) => x.body.len(),
AnyPart::Msg(x) => x.raw_part.len(),
AnyPart::Mult(_) => 0,
};
let m = self.0.mime();
let parameter_list = m
.ctype
.as_ref()
.map(|x| {
x.params
.iter()
.map(|p| {
(
IString::try_from(String::from_utf8_lossy(p.name).to_string()),
IString::try_from(p.value.to_string()),
)
})
.filter(|(k, v)| k.is_ok() && v.is_ok())
.map(|(k, v)| (k.unwrap(), v.unwrap()))
.collect()
})
.unwrap_or(vec![]);
Ok(BasicFields {
parameter_list,
id: NString(
m.id.as_ref()
.and_then(|ci| IString::try_from(ci.to_string()).ok()),
),
description: NString(
m.description
.as_ref()
.and_then(|cd| IString::try_from(cd.to_string()).ok()),
),
content_transfer_encoding: match m.transfer_encoding {
mime::mechanism::Mechanism::_8Bit => unchecked_istring("8bit"),
mime::mechanism::Mechanism::Binary => unchecked_istring("binary"),
mime::mechanism::Mechanism::QuotedPrintable => {
unchecked_istring("quoted-printable")
}
mime::mechanism::Mechanism::Base64 => unchecked_istring("base64"),
_ => unchecked_istring("7bit"),
},
// @FIXME we can't compute the size of the message currently...
size: u32::try_from(sz)?,
})
}
}
// ---------------------------
struct NodeMsg<'a>(&'a NodeMime<'a>, &'a composite::Message<'a>);
impl<'a> NodeMsg<'a> {
fn structure(&self, is_ext: bool) -> Result<BodyStructure<'static>> {
let basic = SelectedMime(self.0 .0).basic_fields()?;
Ok(BodyStructure::Single {
body: FetchBody {
basic,
specific: SpecificFields::Message {
envelope: Box::new(ImfView(&self.1.imf).message_envelope()),
body_structure: Box::new(NodeMime(&self.1.child).structure(is_ext)?),
number_of_lines: nol(self.1.raw_part),
},
},
extension_data: match is_ext {
true => Some(SinglePartExtensionData {
md5: NString(None),
tail: None,
}),
_ => None,
},
})
}
}
struct NodeMult<'a>(&'a NodeMime<'a>, &'a composite::Multipart<'a>);
impl<'a> NodeMult<'a> {
fn structure(&self, is_ext: bool) -> Result<BodyStructure<'static>> {
let itype = &self.1.mime.interpreted_type;
let subtype = IString::try_from(itype.subtype.to_string())
.unwrap_or(unchecked_istring("alternative"));
let inner_bodies = self
.1
.children
.iter()
.filter_map(|inner| NodeMime(&inner).structure(is_ext).ok())
.collect::<Vec<_>>();
Vec1::validate(&inner_bodies)?;
let bodies = Vec1::unvalidated(inner_bodies);
Ok(BodyStructure::Multi {
bodies,
subtype,
extension_data: match is_ext {
true => Some(MultiPartExtensionData {
parameter_list: vec![(
IString::try_from("boundary").unwrap(),
IString::try_from(self.1.mime.interpreted_type.boundary.to_string())?,
)],
tail: None,
}),
_ => None,
},
})
}
}
struct NodeTxt<'a>(&'a NodeMime<'a>, &'a discrete::Text<'a>);
impl<'a> NodeTxt<'a> {
fn structure(&self, is_ext: bool) -> Result<BodyStructure<'static>> {
let mut basic = SelectedMime(self.0 .0).basic_fields()?;
// Get the interpreted content type, set it
let itype = match &self.1.mime.interpreted_type {
Deductible::Inferred(v) | Deductible::Explicit(v) => v,
};
let subtype =
IString::try_from(itype.subtype.to_string()).unwrap_or(unchecked_istring("plain"));
// Add charset to the list of parameters if we know it has been inferred as it will be
// missing from the parsed content.
if let Deductible::Inferred(charset) = &itype.charset {
basic.parameter_list.push((
unchecked_istring("charset"),
IString::try_from(charset.to_string()).unwrap_or(unchecked_istring("us-ascii")),
));
}
Ok(BodyStructure::Single {
body: FetchBody {
basic,
specific: SpecificFields::Text {
subtype,
number_of_lines: nol(self.1.body),
},
},
extension_data: match is_ext {
true => Some(SinglePartExtensionData {
md5: NString(None),
tail: None,
}),
_ => None,
},
})
}
}
struct NodeBin<'a>(&'a NodeMime<'a>, &'a discrete::Binary<'a>);
impl<'a> NodeBin<'a> {
fn structure(&self, is_ext: bool) -> Result<BodyStructure<'static>> {
let basic = SelectedMime(self.0 .0).basic_fields()?;
let default = mime::r#type::NaiveType {
main: &b"application"[..],
sub: &b"octet-stream"[..],
params: vec![],
};
let ct = self.1.mime.fields.ctype.as_ref().unwrap_or(&default);
let r#type = IString::try_from(String::from_utf8_lossy(ct.main).to_string()).or(Err(
anyhow!("Unable to build IString from given Content-Type type given"),
))?;
let subtype = IString::try_from(String::from_utf8_lossy(ct.sub).to_string()).or(Err(
anyhow!("Unable to build IString from given Content-Type subtype given"),
))?;
Ok(BodyStructure::Single {
body: FetchBody {
basic,
specific: SpecificFields::Basic { r#type, subtype },
},
extension_data: match is_ext {
true => Some(SinglePartExtensionData {
md5: NString(None),
tail: None,
}),
_ => None,
},
})
}
}
// ---------------------------
struct ExtractedFull<'a>(Cow<'a, [u8]>);
impl<'a> ExtractedFull<'a> {
/// It is possible to fetch a substring of the designated text.
/// This is done by appending an open angle bracket ("<"), the
/// octet position of the first desired octet, a period, the
/// maximum number of octets desired, and a close angle bracket
/// (">") to the part specifier. If the starting octet is beyond
/// the end of the text, an empty string is returned.
///
/// Any partial fetch that attempts to read beyond the end of the
/// text is truncated as appropriate. A partial fetch that starts
/// at octet 0 is returned as a partial fetch, even if this
/// truncation happened.
///
/// Note: This means that BODY[]<0.2048> of a 1500-octet message
/// will return BODY[]<0> with a literal of size 1500, not
/// BODY[].
///
/// Note: A substring fetch of a HEADER.FIELDS or
/// HEADER.FIELDS.NOT part specifier is calculated after
/// subsetting the header.
fn to_body_section(self, partial: &'_ Option<(u32, NonZeroU32)>) -> BodySection<'a> {
match partial {
Some((begin, len)) => self.partialize(*begin, *len),
None => BodySection::Full(self.0),
}
}
fn partialize(self, begin: u32, len: NonZeroU32) -> BodySection<'a> {
// Asked range is starting after the end of the content,
// returning an empty buffer
if begin as usize > self.0.len() {
return BodySection::Slice {
body: Cow::Borrowed(&[][..]),
origin_octet: begin,
};
}
// Asked range is ending after the end of the content,
// slice only the beginning of the buffer
if (begin + len.get()) as usize >= self.0.len() {
return BodySection::Slice {
body: match self.0 {
Cow::Borrowed(body) => Cow::Borrowed(&body[begin as usize..]),
Cow::Owned(body) => Cow::Owned(body[begin as usize..].to_vec()),
},
origin_octet: begin,
};
}
// Range is included inside the considered content,
// this is the "happy case"
BodySection::Slice {
body: match self.0 {
Cow::Borrowed(body) => {
Cow::Borrowed(&body[begin as usize..(begin + len.get()) as usize])
}
Cow::Owned(body) => {
Cow::Owned(body[begin as usize..(begin + len.get()) as usize].to_vec())
}
},
origin_octet: begin,
}
}
}
/// ---- LEGACY
/// s is set to static to ensure that only compile time values
/// checked by developpers are passed.
fn unchecked_istring(s: &'static str) -> IString {
IString::try_from(s).expect("this value is expected to be a valid imap-codec::IString")
}
// Number Of Lines
fn nol(input: &[u8]) -> u32 {
input
.iter()
.filter(|x| **x == b'\n')
.count()
.try_into()
.unwrap_or(0)
}

View file

@ -1,105 +1,421 @@
mod attributes;
mod capability;
mod command;
mod flags;
mod flow;
mod imf_view;
mod index;
mod mail_view;
mod mailbox_view;
mod mime_view;
mod request;
mod response;
mod search;
mod session;
use std::task::{Context, Poll};
use std::net::SocketAddr;
use anyhow::Result;
use boitalettres::errors::Error as BalError;
use boitalettres::proto::{Request, Response};
use boitalettres::server::accept::addr::AddrIncoming;
use boitalettres::server::accept::addr::AddrStream;
use boitalettres::server::Server as ImapServer;
use futures::future::BoxFuture;
use futures::future::FutureExt;
use anyhow::{anyhow, bail, Context, Result};
use futures::stream::{FuturesUnordered, StreamExt};
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tokio::sync::watch;
use tower::Service;
use crate::config::ImapConfig;
use imap_codec::imap_types::response::{Code, CommandContinuationRequest, Response, Status};
use imap_codec::imap_types::{core::Text, response::Greeting};
use imap_flow::server::{ServerFlow, ServerFlowEvent, ServerFlowOptions};
use imap_flow::stream::AnyStream;
use rustls_pemfile::{certs, private_key};
use tokio_rustls::TlsAcceptor;
use crate::config::{ImapConfig, ImapUnsecureConfig};
use crate::imap::capability::ServerCapability;
use crate::imap::request::Request;
use crate::imap::response::{Body, ResponseOrIdle};
use crate::imap::session::Instance;
use crate::login::ArcLoginProvider;
/// Server is a thin wrapper to register our Services in BàL
pub struct Server(ImapServer<AddrIncoming, Instance>);
pub struct Server {
bind_addr: SocketAddr,
login_provider: ArcLoginProvider,
capabilities: ServerCapability,
tls: Option<TlsAcceptor>,
}
pub async fn new(config: ImapConfig, login: ArcLoginProvider) -> Result<Server> {
//@FIXME add a configuration parameter
let incoming = AddrIncoming::new(config.bind_addr).await?;
tracing::info!("IMAP activated, will listen on {:#}", incoming.local_addr);
#[derive(Clone)]
struct ClientContext {
addr: SocketAddr,
login_provider: ArcLoginProvider,
must_exit: watch::Receiver<bool>,
server_capabilities: ServerCapability,
}
let imap = ImapServer::new(incoming).serve(Instance::new(login.clone()));
Ok(Server(imap))
pub fn new(config: ImapConfig, login: ArcLoginProvider) -> Result<Server> {
let loaded_certs = certs(&mut std::io::BufReader::new(std::fs::File::open(
config.certs,
)?))
.collect::<Result<Vec<_>, _>>()?;
let loaded_key = private_key(&mut std::io::BufReader::new(std::fs::File::open(
config.key,
)?))?
.unwrap();
let tls_config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(loaded_certs, loaded_key)?;
let acceptor = TlsAcceptor::from(Arc::new(tls_config));
Ok(Server {
bind_addr: config.bind_addr,
login_provider: login,
capabilities: ServerCapability::default(),
tls: Some(acceptor),
})
}
pub fn new_unsecure(config: ImapUnsecureConfig, login: ArcLoginProvider) -> Server {
Server {
bind_addr: config.bind_addr,
login_provider: login,
capabilities: ServerCapability::default(),
tls: None,
}
}
impl Server {
pub async fn run(self, mut must_exit: watch::Receiver<bool>) -> Result<()> {
tracing::info!("IMAP started!");
tokio::select! {
s = self.0 => s?,
_ = must_exit.changed() => tracing::info!("Stopped IMAP server"),
pub async fn run(self: Self, mut must_exit: watch::Receiver<bool>) -> Result<()> {
let tcp = TcpListener::bind(self.bind_addr).await?;
tracing::info!("IMAP server listening on {:#}", self.bind_addr);
let mut connections = FuturesUnordered::new();
while !*must_exit.borrow() {
let wait_conn_finished = async {
if connections.is_empty() {
futures::future::pending().await
} else {
connections.next().await
}
};
let (socket, remote_addr) = tokio::select! {
a = tcp.accept() => a?,
_ = wait_conn_finished => continue,
_ = must_exit.changed() => continue,
};
tracing::info!("IMAP: accepted connection from {}", remote_addr);
let stream = match self.tls.clone() {
Some(acceptor) => {
let stream = match acceptor.accept(socket).await {
Ok(v) => v,
Err(e) => {
tracing::error!(err=?e, "TLS negociation failed");
continue;
}
};
AnyStream::new(stream)
}
None => AnyStream::new(socket),
};
let client = ClientContext {
addr: remote_addr.clone(),
login_provider: self.login_provider.clone(),
must_exit: must_exit.clone(),
server_capabilities: self.capabilities.clone(),
};
let conn = tokio::spawn(NetLoop::handler(client, stream));
connections.push(conn);
}
drop(tcp);
tracing::info!("IMAP server shutting down, draining remaining connections...");
while connections.next().await.is_some() {}
Ok(())
}
}
//---
use std::sync::Arc;
use tokio::sync::mpsc::*;
use tokio::sync::Notify;
use tokio_util::bytes::BytesMut;
/// Instance is the main Tokio Tower service that we register in BàL.
/// It receives new connection demands and spawn a dedicated service.
struct Instance {
login_provider: ArcLoginProvider,
const PIPELINABLE_COMMANDS: usize = 64;
// @FIXME a full refactor of this part of the code will be needed sooner or later
struct NetLoop {
ctx: ClientContext,
server: ServerFlow,
cmd_tx: Sender<Request>,
resp_rx: UnboundedReceiver<ResponseOrIdle>,
}
impl Instance {
pub fn new(login_provider: ArcLoginProvider) -> Self {
Self { login_provider }
}
}
impl NetLoop {
async fn handler(ctx: ClientContext, sock: AnyStream) {
let addr = ctx.addr.clone();
impl<'a> Service<&'a AddrStream> for Instance {
type Response = Connection;
type Error = anyhow::Error;
type Future = BoxFuture<'static, Result<Self::Response>>;
let mut nl = match Self::new(ctx, sock).await {
Ok(nl) => {
tracing::debug!(addr=?addr, "netloop successfully initialized");
nl
}
Err(e) => {
tracing::error!(addr=?addr, err=?e, "netloop can not be initialized, closing session");
return;
}
};
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, addr: &'a AddrStream) -> Self::Future {
tracing::info!(remote_addr = %addr.remote_addr, local_addr = %addr.local_addr, "accept");
let lp = self.login_provider.clone();
async { Ok(Connection::new(lp)) }.boxed()
}
}
//---
/// Connection is the per-connection Tokio Tower service we register in BàL.
/// It handles a single TCP connection, and thus has a business logic.
struct Connection {
session: session::Manager,
}
impl Connection {
pub fn new(login_provider: ArcLoginProvider) -> Self {
Self {
session: session::Manager::new(login_provider),
match nl.core().await {
Ok(()) => {
tracing::debug!("closing successful netloop core for {:?}", addr);
}
Err(e) => {
tracing::error!("closing errored netloop core for {:?}: {}", addr, e);
}
}
}
}
impl Service<Request> for Connection {
type Response = Response;
type Error = BalError;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
async fn new(ctx: ClientContext, sock: AnyStream) -> Result<Self> {
let mut opts = ServerFlowOptions::default();
opts.crlf_relaxed = false;
opts.literal_accept_text = Text::unvalidated("OK");
opts.literal_reject_text = Text::unvalidated("Literal rejected");
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
// Send greeting
let (server, _) = ServerFlow::send_greeting(
sock,
opts,
Greeting::ok(
Some(Code::Capability(ctx.server_capabilities.to_vec())),
"Aerogramme",
)
.unwrap(),
)
.await?;
// Start a mailbox session in background
let (cmd_tx, cmd_rx) = mpsc::channel::<Request>(PIPELINABLE_COMMANDS);
let (resp_tx, resp_rx) = mpsc::unbounded_channel::<ResponseOrIdle>();
tokio::spawn(Self::session(ctx.clone(), cmd_rx, resp_tx));
// Return the object
Ok(NetLoop {
ctx,
server,
cmd_tx,
resp_rx,
})
}
fn call(&mut self, req: Request) -> Self::Future {
tracing::debug!("Got request: {:#?}", req.command);
self.session.process(req)
/// Coms with the background session
async fn session(
ctx: ClientContext,
mut cmd_rx: Receiver<Request>,
resp_tx: UnboundedSender<ResponseOrIdle>,
) -> () {
let mut session = Instance::new(ctx.login_provider, ctx.server_capabilities);
loop {
let cmd = match cmd_rx.recv().await {
None => break,
Some(cmd_recv) => cmd_recv,
};
tracing::debug!(cmd=?cmd, sock=%ctx.addr, "command");
let maybe_response = session.request(cmd).await;
tracing::debug!(cmd=?maybe_response, sock=%ctx.addr, "response");
match resp_tx.send(maybe_response) {
Err(_) => break,
Ok(_) => (),
};
}
tracing::info!("runner is quitting");
}
async fn core(&mut self) -> Result<()> {
let mut maybe_idle: Option<Arc<Notify>> = None;
loop {
tokio::select! {
// Managing imap_flow stuff
srv_evt = self.server.progress() => match srv_evt? {
ServerFlowEvent::ResponseSent { handle: _handle, response } => {
match response {
Response::Status(Status::Bye(_)) => return Ok(()),
_ => tracing::trace!("sent to {} content {:?}", self.ctx.addr, response),
}
},
ServerFlowEvent::CommandReceived { command } => {
match self.cmd_tx.try_send(Request::ImapCommand(command)) {
Ok(_) => (),
Err(mpsc::error::TrySendError::Full(_)) => {
self.server.enqueue_status(Status::bye(None, "Too fast").unwrap());
tracing::error!("client {:?} is sending commands too fast, closing.", self.ctx.addr);
}
_ => {
self.server.enqueue_status(Status::bye(None, "Internal session exited").unwrap());
tracing::error!("session task exited for {:?}, quitting", self.ctx.addr);
}
}
},
ServerFlowEvent::IdleCommandReceived { tag } => {
match self.cmd_tx.try_send(Request::IdleStart(tag)) {
Ok(_) => (),
Err(mpsc::error::TrySendError::Full(_)) => {
self.server.enqueue_status(Status::bye(None, "Too fast").unwrap());
tracing::error!("client {:?} is sending commands too fast, closing.", self.ctx.addr);
}
_ => {
self.server.enqueue_status(Status::bye(None, "Internal session exited").unwrap());
tracing::error!("session task exited for {:?}, quitting", self.ctx.addr);
}
}
}
ServerFlowEvent::IdleDoneReceived => {
tracing::trace!("client sent DONE and want to stop IDLE");
maybe_idle.ok_or(anyhow!("Received IDLE done but not idling currently"))?.notify_one();
maybe_idle = None;
}
flow => {
self.server.enqueue_status(Status::bye(None, "Unsupported server flow event").unwrap());
tracing::error!("session task exited for {:?} due to unsupported flow {:?}", self.ctx.addr, flow);
}
},
// Managing response generated by Aerogramme
maybe_msg = self.resp_rx.recv() => match maybe_msg {
Some(ResponseOrIdle::Response(response)) => {
tracing::trace!("Interactive, server has a response for the client");
for body_elem in response.body.into_iter() {
let _handle = match body_elem {
Body::Data(d) => self.server.enqueue_data(d),
Body::Status(s) => self.server.enqueue_status(s),
};
}
self.server.enqueue_status(response.completion);
},
Some(ResponseOrIdle::IdleAccept(stop)) => {
tracing::trace!("Interactive, server agreed to switch in idle mode");
let cr = CommandContinuationRequest::basic(None, "Idling")?;
self.server.idle_accept(cr).or(Err(anyhow!("refused continuation for idle accept")))?;
self.cmd_tx.try_send(Request::IdlePoll)?;
if maybe_idle.is_some() {
bail!("Can't start IDLE if already idling");
}
maybe_idle = Some(stop);
},
Some(ResponseOrIdle::IdleEvent(elems)) => {
tracing::trace!("server imap session has some change to communicate to the client");
for body_elem in elems.into_iter() {
let _handle = match body_elem {
Body::Data(d) => self.server.enqueue_data(d),
Body::Status(s) => self.server.enqueue_status(s),
};
}
self.cmd_tx.try_send(Request::IdlePoll)?;
},
Some(ResponseOrIdle::IdleReject(response)) => {
tracing::trace!("inform client that session rejected idle");
self.server
.idle_reject(response.completion)
.or(Err(anyhow!("wrong reject command")))?;
},
None => {
self.server.enqueue_status(Status::bye(None, "Internal session exited").unwrap());
tracing::error!("session task exited for {:?}, quitting", self.ctx.addr);
},
Some(_) => unreachable!(),
},
// When receiving a CTRL+C
_ = self.ctx.must_exit.changed() => {
tracing::trace!("Interactive, CTRL+C, exiting");
self.server.enqueue_status(Status::bye(None, "Server is being shutdown").unwrap());
},
};
}
}
/*
async fn idle_mode(&mut self, mut buff: BytesMut, stop: Arc<Notify>) -> Result<LoopMode> {
// Flush send
loop {
tracing::trace!("flush server send");
match self.server.progress_send().await? {
Some(..) => continue,
None => break,
}
}
tokio::select! {
// Receiving IDLE event from background
maybe_msg = self.resp_rx.recv() => match maybe_msg {
// Session decided idle is terminated
Some(ResponseOrIdle::Response(response)) => {
tracing::trace!("server imap session said idle is done, sending response done, switching to interactive");
for body_elem in response.body.into_iter() {
let _handle = match body_elem {
Body::Data(d) => self.server.enqueue_data(d),
Body::Status(s) => self.server.enqueue_status(s),
};
}
self.server.enqueue_status(response.completion);
return Ok(LoopMode::Interactive)
},
// Session has some information for user
Some(ResponseOrIdle::IdleEvent(elems)) => {
tracing::trace!("server imap session has some change to communicate to the client");
for body_elem in elems.into_iter() {
let _handle = match body_elem {
Body::Data(d) => self.server.enqueue_data(d),
Body::Status(s) => self.server.enqueue_status(s),
};
}
self.cmd_tx.try_send(Request::Idle)?;
return Ok(LoopMode::Idle(buff, stop))
},
// Session crashed
None => {
self.server.enqueue_status(Status::bye(None, "Internal session exited").unwrap());
tracing::error!("session task exited for {:?}, quitting", self.ctx.addr);
return Ok(LoopMode::Interactive)
},
// Session can't start idling while already idling, it's a logic error!
Some(ResponseOrIdle::StartIdle(..)) => bail!("can't start idling while already idling!"),
},
// User is trying to interact with us
read_client_result = self.server.stream.read(&mut buff) => {
let _bytes_read = read_client_result?;
use imap_codec::decode::Decoder;
let codec = imap_codec::IdleDoneCodec::new();
tracing::trace!("client sent some data for the server IMAP session");
match codec.decode(&buff) {
Ok(([], imap_codec::imap_types::extensions::idle::IdleDone)) => {
// Session will be informed that it must stop idle
// It will generate the "done" message and change the loop mode
tracing::trace!("client sent DONE and want to stop IDLE");
stop.notify_one()
},
Err(_) => {
tracing::trace!("Unable to decode DONE, maybe not enough data were sent?");
},
_ => bail!("Client sent data after terminating the continuation without waiting for the server. This is an unsupported behavior and bug in Aerogramme, quitting."),
};
return Ok(LoopMode::Idle(buff, stop))
},
// When receiving a CTRL+C
_ = self.ctx.must_exit.changed() => {
tracing::trace!("CTRL+C sent, aborting IDLE for this session");
self.server.enqueue_status(Status::bye(None, "Server is being shutdown").unwrap());
return Ok(LoopMode::Interactive)
},
};
}*/
}

9
src/imap/request.rs Normal file
View file

@ -0,0 +1,9 @@
use imap_codec::imap_types::command::Command;
use imap_codec::imap_types::core::Tag;
#[derive(Debug)]
pub enum Request {
ImapCommand(Command<'static>),
IdleStart(Tag<'static>),
IdlePoll,
}

124
src/imap/response.rs Normal file
View file

@ -0,0 +1,124 @@
use anyhow::Result;
use imap_codec::imap_types::command::Command;
use imap_codec::imap_types::core::Tag;
use imap_codec::imap_types::response::{Code, Data, Status};
use std::sync::Arc;
use tokio::sync::Notify;
#[derive(Debug)]
pub enum Body<'a> {
Data(Data<'a>),
Status(Status<'a>),
}
pub struct ResponseBuilder<'a> {
tag: Option<Tag<'a>>,
code: Option<Code<'a>>,
text: String,
body: Vec<Body<'a>>,
}
impl<'a> ResponseBuilder<'a> {
pub fn to_req(mut self, cmd: &Command<'a>) -> Self {
self.tag = Some(cmd.tag.clone());
self
}
pub fn tag(mut self, tag: Tag<'a>) -> Self {
self.tag = Some(tag);
self
}
pub fn message(mut self, txt: impl Into<String>) -> Self {
self.text = txt.into();
self
}
pub fn code(mut self, code: Code<'a>) -> Self {
self.code = Some(code);
self
}
pub fn data(mut self, data: Data<'a>) -> Self {
self.body.push(Body::Data(data));
self
}
pub fn many_data(mut self, data: Vec<Data<'a>>) -> Self {
for d in data.into_iter() {
self = self.data(d);
}
self
}
#[allow(dead_code)]
pub fn info(mut self, status: Status<'a>) -> Self {
self.body.push(Body::Status(status));
self
}
#[allow(dead_code)]
pub fn many_info(mut self, status: Vec<Status<'a>>) -> Self {
for d in status.into_iter() {
self = self.info(d);
}
self
}
pub fn set_body(mut self, body: Vec<Body<'a>>) -> Self {
self.body = body;
self
}
pub fn ok(self) -> Result<Response<'a>> {
Ok(Response {
completion: Status::ok(self.tag, self.code, self.text)?,
body: self.body,
})
}
pub fn no(self) -> Result<Response<'a>> {
Ok(Response {
completion: Status::no(self.tag, self.code, self.text)?,
body: self.body,
})
}
pub fn bad(self) -> Result<Response<'a>> {
Ok(Response {
completion: Status::bad(self.tag, self.code, self.text)?,
body: self.body,
})
}
}
#[derive(Debug)]
pub struct Response<'a> {
pub body: Vec<Body<'a>>,
pub completion: Status<'a>,
}
impl<'a> Response<'a> {
pub fn build() -> ResponseBuilder<'a> {
ResponseBuilder {
tag: None,
code: None,
text: "".to_string(),
body: vec![],
}
}
pub fn bye() -> Result<Response<'a>> {
Ok(Response {
completion: Status::bye(None, "bye")?,
body: vec![],
})
}
}
#[derive(Debug)]
pub enum ResponseOrIdle {
Response(Response<'static>),
IdleAccept(Arc<Notify>),
IdleReject(Response<'static>),
IdleEvent(Vec<Body<'static>>),
}

477
src/imap/search.rs Normal file
View file

@ -0,0 +1,477 @@
use std::num::{NonZeroU32, NonZeroU64};
use imap_codec::imap_types::core::Vec1;
use imap_codec::imap_types::search::{MetadataItemSearch, SearchKey};
use imap_codec::imap_types::sequence::{SeqOrUid, Sequence, SequenceSet};
use crate::imap::index::MailIndex;
use crate::imap::mail_view::MailView;
use crate::mail::query::QueryScope;
pub enum SeqType {
Undefined,
NonUid,
Uid,
}
impl SeqType {
pub fn is_uid(&self) -> bool {
matches!(self, Self::Uid)
}
}
pub struct Criteria<'a>(pub &'a SearchKey<'a>);
impl<'a> Criteria<'a> {
/// Returns a set of email identifiers that is greater or equal
/// to the set of emails to return
pub fn to_sequence_set(&self) -> (SequenceSet, SeqType) {
match self.0 {
SearchKey::All => (sequence_set_all(), SeqType::Undefined),
SearchKey::SequenceSet(seq_set) => (seq_set.clone(), SeqType::NonUid),
SearchKey::Uid(seq_set) => (seq_set.clone(), SeqType::Uid),
SearchKey::Not(_inner) => {
tracing::debug!(
"using NOT in a search request is slow: it selects all identifiers"
);
(sequence_set_all(), SeqType::Undefined)
}
SearchKey::Or(left, right) => {
tracing::debug!("using OR in a search request is slow: no deduplication is done");
let (base, base_seqtype) = Self(&left).to_sequence_set();
let (ext, ext_seqtype) = Self(&right).to_sequence_set();
// Check if we have a UID/ID conflict in fetching: now we don't know how to handle them
match (base_seqtype, ext_seqtype) {
(SeqType::Uid, SeqType::NonUid) | (SeqType::NonUid, SeqType::Uid) => {
(sequence_set_all(), SeqType::Undefined)
}
(SeqType::Undefined, x) | (x, _) => {
let mut new_vec = base.0.into_inner();
new_vec.extend_from_slice(ext.0.as_ref());
let seq = SequenceSet(
Vec1::try_from(new_vec)
.expect("merging non empty vec lead to non empty vec"),
);
(seq, x)
}
}
}
SearchKey::And(search_list) => {
tracing::debug!(
"using AND in a search request is slow: no intersection is performed"
);
// As we perform no intersection, we don't care if we mix uid or id.
// We only keep the smallest range, being it ID or UID, depending of
// which one has the less items. This is an approximation as UID ranges
// can have holes while ID ones can't.
search_list
.as_ref()
.iter()
.map(|crit| Self(&crit).to_sequence_set())
.min_by(|(x, _), (y, _)| {
let x_size = approx_sequence_set_size(x);
let y_size = approx_sequence_set_size(y);
x_size.cmp(&y_size)
})
.unwrap_or((sequence_set_all(), SeqType::Undefined))
}
_ => (sequence_set_all(), SeqType::Undefined),
}
}
/// Not really clever as we can have cases where we filter out
/// the email before needing to inspect its meta.
/// But for now we are seeking the most basic/stupid algorithm.
pub fn query_scope(&self) -> QueryScope {
use SearchKey::*;
match self.0 {
// Combinators
And(and_list) => and_list
.as_ref()
.iter()
.fold(QueryScope::Index, |prev, sk| {
prev.union(&Criteria(sk).query_scope())
}),
Not(inner) => Criteria(inner).query_scope(),
Or(left, right) => Criteria(left)
.query_scope()
.union(&Criteria(right).query_scope()),
All => QueryScope::Index,
// IMF Headers
Bcc(_) | Cc(_) | From(_) | Header(..) | SentBefore(_) | SentOn(_) | SentSince(_)
| Subject(_) | To(_) => QueryScope::Partial,
// Internal Date is also stored in MailMeta
Before(_) | On(_) | Since(_) => QueryScope::Partial,
// Message size is also stored in MailMeta
Larger(_) | Smaller(_) => QueryScope::Partial,
// Text and Body require that we fetch the full content!
Text(_) | Body(_) => QueryScope::Full,
_ => QueryScope::Index,
}
}
pub fn is_modseq(&self) -> bool {
use SearchKey::*;
match self.0 {
And(and_list) => and_list
.as_ref()
.iter()
.any(|child| Criteria(child).is_modseq()),
Or(left, right) => Criteria(left).is_modseq() || Criteria(right).is_modseq(),
Not(child) => Criteria(child).is_modseq(),
ModSeq { .. } => true,
_ => false,
}
}
/// Returns emails that we now for sure we want to keep
/// but also a second list of emails we need to investigate further by
/// fetching some remote data
pub fn filter_on_idx<'b>(
&self,
midx_list: &[&'b MailIndex<'b>],
) -> (Vec<&'b MailIndex<'b>>, Vec<&'b MailIndex<'b>>) {
let (p1, p2): (Vec<_>, Vec<_>) = midx_list
.iter()
.map(|x| (x, self.is_keep_on_idx(x)))
.filter(|(_midx, decision)| decision.is_keep())
.map(|(midx, decision)| (*midx, decision))
.partition(|(_midx, decision)| matches!(decision, PartialDecision::Keep));
let to_keep = p1.into_iter().map(|(v, _)| v).collect();
let to_fetch = p2.into_iter().map(|(v, _)| v).collect();
(to_keep, to_fetch)
}
// ----
/// Here we are doing a partial filtering: we do not have access
/// to the headers or to the body, so every time we encounter a rule
/// based on them, we need to keep it.
///
/// @TODO Could be optimized on a per-email basis by also returning the QueryScope
/// when more information is needed!
fn is_keep_on_idx(&self, midx: &MailIndex) -> PartialDecision {
use SearchKey::*;
match self.0 {
// Combinator logic
And(expr_list) => expr_list
.as_ref()
.iter()
.fold(PartialDecision::Keep, |acc, cur| {
acc.and(&Criteria(cur).is_keep_on_idx(midx))
}),
Or(left, right) => {
let left_decision = Criteria(left).is_keep_on_idx(midx);
let right_decision = Criteria(right).is_keep_on_idx(midx);
left_decision.or(&right_decision)
}
Not(expr) => Criteria(expr).is_keep_on_idx(midx).not(),
All => PartialDecision::Keep,
// Sequence logic
maybe_seq if is_sk_seq(maybe_seq) => is_keep_seq(maybe_seq, midx).into(),
maybe_flag if is_sk_flag(maybe_flag) => is_keep_flag(maybe_flag, midx).into(),
ModSeq {
metadata_item,
modseq,
} => is_keep_modseq(metadata_item, modseq, midx).into(),
// All the stuff we can't evaluate yet
Bcc(_) | Cc(_) | From(_) | Header(..) | SentBefore(_) | SentOn(_) | SentSince(_)
| Subject(_) | To(_) | Before(_) | On(_) | Since(_) | Larger(_) | Smaller(_)
| Text(_) | Body(_) => PartialDecision::Postpone,
unknown => {
tracing::error!("Unknown filter {:?}", unknown);
PartialDecision::Discard
}
}
}
/// @TODO we re-eveluate twice the same logic. The correct way would be, on each pass,
/// to simplify the searck query, by removing the elements that were already checked.
/// For example if we have AND(OR(seqid(X), body(Y)), body(X)), we can't keep for sure
/// the email, as body(x) might be false. So we need to check it. But as seqid(x) is true,
/// we could simplify the request to just body(x) and truncate the first OR. Today, we are
/// not doing that, and thus we reevaluate everything.
pub fn is_keep_on_query(&self, mail_view: &MailView) -> bool {
use SearchKey::*;
match self.0 {
// Combinator logic
And(expr_list) => expr_list
.as_ref()
.iter()
.all(|cur| Criteria(cur).is_keep_on_query(mail_view)),
Or(left, right) => {
Criteria(left).is_keep_on_query(mail_view)
|| Criteria(right).is_keep_on_query(mail_view)
}
Not(expr) => !Criteria(expr).is_keep_on_query(mail_view),
All => true,
//@FIXME Reevaluating our previous logic...
maybe_seq if is_sk_seq(maybe_seq) => is_keep_seq(maybe_seq, &mail_view.in_idx),
maybe_flag if is_sk_flag(maybe_flag) => is_keep_flag(maybe_flag, &mail_view.in_idx),
ModSeq {
metadata_item,
modseq,
} => is_keep_modseq(metadata_item, modseq, &mail_view.in_idx).into(),
// Filter on mail meta
Before(search_naive) => match mail_view.stored_naive_date() {
Ok(msg_naive) => &msg_naive < search_naive.as_ref(),
_ => false,
},
On(search_naive) => match mail_view.stored_naive_date() {
Ok(msg_naive) => &msg_naive == search_naive.as_ref(),
_ => false,
},
Since(search_naive) => match mail_view.stored_naive_date() {
Ok(msg_naive) => &msg_naive > search_naive.as_ref(),
_ => false,
},
// Message size is also stored in MailMeta
Larger(size_ref) => {
mail_view
.query_result
.metadata()
.expect("metadata were fetched")
.rfc822_size
> *size_ref as usize
}
Smaller(size_ref) => {
mail_view
.query_result
.metadata()
.expect("metadata were fetched")
.rfc822_size
< *size_ref as usize
}
// Filter on well-known headers
Bcc(txt) => mail_view.is_header_contains_pattern(&b"bcc"[..], txt.as_ref()),
Cc(txt) => mail_view.is_header_contains_pattern(&b"cc"[..], txt.as_ref()),
From(txt) => mail_view.is_header_contains_pattern(&b"from"[..], txt.as_ref()),
Subject(txt) => mail_view.is_header_contains_pattern(&b"subject"[..], txt.as_ref()),
To(txt) => mail_view.is_header_contains_pattern(&b"to"[..], txt.as_ref()),
Header(hdr, txt) => mail_view.is_header_contains_pattern(hdr.as_ref(), txt.as_ref()),
// Filter on Date header
SentBefore(search_naive) => mail_view
.imf()
.map(|imf| imf.naive_date().ok())
.flatten()
.map(|msg_naive| &msg_naive < search_naive.as_ref())
.unwrap_or(false),
SentOn(search_naive) => mail_view
.imf()
.map(|imf| imf.naive_date().ok())
.flatten()
.map(|msg_naive| &msg_naive == search_naive.as_ref())
.unwrap_or(false),
SentSince(search_naive) => mail_view
.imf()
.map(|imf| imf.naive_date().ok())
.flatten()
.map(|msg_naive| &msg_naive > search_naive.as_ref())
.unwrap_or(false),
// Filter on the full content of the email
Text(txt) => mail_view
.content
.as_msg()
.map(|msg| {
msg.raw_part
.windows(txt.as_ref().len())
.any(|win| win == txt.as_ref())
})
.unwrap_or(false),
Body(txt) => mail_view
.content
.as_msg()
.map(|msg| {
msg.raw_body
.windows(txt.as_ref().len())
.any(|win| win == txt.as_ref())
})
.unwrap_or(false),
unknown => {
tracing::error!("Unknown filter {:?}", unknown);
false
}
}
}
}
// ---- Sequence things ----
fn sequence_set_all() -> SequenceSet {
SequenceSet::from(Sequence::Range(
SeqOrUid::Value(NonZeroU32::MIN),
SeqOrUid::Asterisk,
))
}
// This is wrong as sequences can overlap
fn approx_sequence_set_size(seq_set: &SequenceSet) -> u64 {
seq_set.0.as_ref().iter().fold(0u64, |acc, seq| {
acc.saturating_add(approx_sequence_size(seq))
})
}
// This is wrong as sequence UID can have holes,
// as we don't know the number of messages in the mailbox also
// we gave to guess
fn approx_sequence_size(seq: &Sequence) -> u64 {
match seq {
Sequence::Single(_) => 1,
Sequence::Range(SeqOrUid::Asterisk, _) | Sequence::Range(_, SeqOrUid::Asterisk) => u64::MAX,
Sequence::Range(SeqOrUid::Value(x1), SeqOrUid::Value(x2)) => {
let x2 = x2.get() as i64;
let x1 = x1.get() as i64;
(x2 - x1).abs().try_into().unwrap_or(1)
}
}
}
// --- Partial decision things ----
enum PartialDecision {
Keep,
Discard,
Postpone,
}
impl From<bool> for PartialDecision {
fn from(x: bool) -> Self {
match x {
true => PartialDecision::Keep,
_ => PartialDecision::Discard,
}
}
}
impl PartialDecision {
fn not(&self) -> Self {
match self {
Self::Keep => Self::Discard,
Self::Discard => Self::Keep,
Self::Postpone => Self::Postpone,
}
}
fn or(&self, other: &Self) -> Self {
match (self, other) {
(Self::Keep, _) | (_, Self::Keep) => Self::Keep,
(Self::Postpone, _) | (_, Self::Postpone) => Self::Postpone,
(Self::Discard, Self::Discard) => Self::Discard,
}
}
fn and(&self, other: &Self) -> Self {
match (self, other) {
(Self::Discard, _) | (_, Self::Discard) => Self::Discard,
(Self::Postpone, _) | (_, Self::Postpone) => Self::Postpone,
(Self::Keep, Self::Keep) => Self::Keep,
}
}
fn is_keep(&self) -> bool {
!matches!(self, Self::Discard)
}
}
// ----- Search Key things ---
fn is_sk_flag(sk: &SearchKey) -> bool {
use SearchKey::*;
match sk {
Answered | Deleted | Draft | Flagged | Keyword(..) | New | Old | Recent | Seen
| Unanswered | Undeleted | Undraft | Unflagged | Unkeyword(..) | Unseen => true,
_ => false,
}
}
fn is_keep_flag(sk: &SearchKey, midx: &MailIndex) -> bool {
use SearchKey::*;
match sk {
Answered => midx.is_flag_set("\\Answered"),
Deleted => midx.is_flag_set("\\Deleted"),
Draft => midx.is_flag_set("\\Draft"),
Flagged => midx.is_flag_set("\\Flagged"),
Keyword(kw) => midx.is_flag_set(kw.inner()),
New => {
let is_recent = midx.is_flag_set("\\Recent");
let is_seen = midx.is_flag_set("\\Seen");
is_recent && !is_seen
}
Old => {
let is_recent = midx.is_flag_set("\\Recent");
!is_recent
}
Recent => midx.is_flag_set("\\Recent"),
Seen => midx.is_flag_set("\\Seen"),
Unanswered => {
let is_answered = midx.is_flag_set("\\Recent");
!is_answered
}
Undeleted => {
let is_deleted = midx.is_flag_set("\\Deleted");
!is_deleted
}
Undraft => {
let is_draft = midx.is_flag_set("\\Draft");
!is_draft
}
Unflagged => {
let is_flagged = midx.is_flag_set("\\Flagged");
!is_flagged
}
Unkeyword(kw) => {
let is_keyword_set = midx.is_flag_set(kw.inner());
!is_keyword_set
}
Unseen => {
let is_seen = midx.is_flag_set("\\Seen");
!is_seen
}
// Not flag logic
_ => unreachable!(),
}
}
fn is_sk_seq(sk: &SearchKey) -> bool {
use SearchKey::*;
match sk {
SequenceSet(..) | Uid(..) => true,
_ => false,
}
}
fn is_keep_seq(sk: &SearchKey, midx: &MailIndex) -> bool {
use SearchKey::*;
match sk {
SequenceSet(seq_set) => seq_set
.0
.as_ref()
.iter()
.any(|seq| midx.is_in_sequence_i(seq)),
Uid(seq_set) => seq_set
.0
.as_ref()
.iter()
.any(|seq| midx.is_in_sequence_uid(seq)),
_ => unreachable!(),
}
}
fn is_keep_modseq(
filter: &Option<MetadataItemSearch>,
modseq: &NonZeroU64,
midx: &MailIndex,
) -> bool {
if filter.is_some() {
tracing::warn!(filter=?filter, "Ignoring search metadata filter as it's not supported yet");
}
modseq <= &midx.modseq
}

View file

@ -1,180 +1,173 @@
use anyhow::Error;
use boitalettres::errors::Error as BalError;
use boitalettres::proto::{Request, Response};
use futures::future::BoxFuture;
use futures::future::FutureExt;
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::{mpsc, oneshot};
use crate::imap::command::{anonymous, authenticated, examined, selected};
use crate::imap::capability::{ClientCapability, ServerCapability};
use crate::imap::command::{anonymous, authenticated, selected};
use crate::imap::flow;
use crate::imap::request::Request;
use crate::imap::response::{Response, ResponseOrIdle};
use crate::login::ArcLoginProvider;
/* This constant configures backpressure in the system,
* or more specifically, how many pipelined messages are allowed
* before refusing them
*/
const MAX_PIPELINED_COMMANDS: usize = 10;
struct Message {
req: Request,
tx: oneshot::Sender<Result<Response, BalError>>,
}
use anyhow::{anyhow, bail, Context, Result};
use imap_codec::imap_types::{command::Command, core::Tag};
//-----
pub struct Manager {
tx: mpsc::Sender<Message>,
}
impl Manager {
pub fn new(login_provider: ArcLoginProvider) -> Self {
let (tx, rx) = mpsc::channel(MAX_PIPELINED_COMMANDS);
tokio::spawn(async move {
let instance = Instance::new(login_provider, rx);
instance.start().await;
});
Self { tx }
}
pub fn process(&self, req: Request) -> BoxFuture<'static, Result<Response, BalError>> {
let (tx, rx) = oneshot::channel();
let msg = Message { req, tx };
// We use try_send on a bounded channel to protect the daemons from DoS.
// Pipelining requests in IMAP are a special case: they should not occure often
// and in a limited number (like 3 requests). Someone filling the channel
// will probably be malicious so we "rate limit" them.
match self.tx.try_send(msg) {
Ok(()) => (),
Err(TrySendError::Full(_)) => {
return async { Response::bad("Too fast! Send less pipelined requests.") }.boxed()
}
Err(TrySendError::Closed(_)) => {
return async { Err(BalError::Text("Terminated session".to_string())) }.boxed()
}
};
// @FIXME add a timeout, handle a session that fails.
async {
match rx.await {
Ok(r) => r,
Err(e) => {
tracing::warn!("Got error {:#?}", e);
Response::bad("No response from the session handler")
}
}
}
.boxed()
}
}
//-----
pub struct Instance {
rx: mpsc::Receiver<Message>,
pub login_provider: ArcLoginProvider,
pub server_capabilities: ServerCapability,
pub client_capabilities: ClientCapability,
pub state: flow::State,
}
impl Instance {
fn new(login_provider: ArcLoginProvider, rx: mpsc::Receiver<Message>) -> Self {
pub fn new(login_provider: ArcLoginProvider, cap: ServerCapability) -> Self {
let client_cap = ClientCapability::new(&cap);
Self {
login_provider,
rx,
state: flow::State::NotAuthenticated,
server_capabilities: cap,
client_capabilities: client_cap,
}
}
//@FIXME add a function that compute the runner's name from its local info
// to ease debug
// fn name(&self) -> String { }
pub async fn request(&mut self, req: Request) -> ResponseOrIdle {
match req {
Request::IdleStart(tag) => self.idle_init(tag),
Request::IdlePoll => self.idle_poll().await,
Request::ImapCommand(cmd) => self.command(cmd).await,
}
}
async fn start(mut self) {
//@FIXME add more info about the runner
tracing::debug!("starting runner");
pub fn idle_init(&mut self, tag: Tag<'static>) -> ResponseOrIdle {
// Build transition
//@FIXME the notifier should be hidden inside the state and thus not part of the transition!
let transition = flow::Transition::Idle(tag.clone(), tokio::sync::Notify::new());
while let Some(msg) = self.rx.recv().await {
// Command behavior is modulated by the state.
// To prevent state error, we handle the same command in separate code paths.
let ctrl = match &mut self.state {
flow::State::NotAuthenticated => {
let ctx = anonymous::AnonymousContext {
req: &msg.req,
login_provider: Some(&self.login_provider),
};
anonymous::dispatch(ctx).await
}
flow::State::Authenticated(ref user) => {
let ctx = authenticated::AuthenticatedContext {
req: &msg.req,
user,
};
authenticated::dispatch(ctx).await
}
flow::State::Selected(ref user, ref mut mailbox) => {
let ctx = selected::SelectedContext {
req: &msg.req,
user,
mailbox,
};
selected::dispatch(ctx).await
}
flow::State::Examined(ref user, ref mut mailbox) => {
let ctx = examined::ExaminedContext {
req: &msg.req,
user,
mailbox,
};
examined::dispatch(ctx).await
}
flow::State::Logout => {
Response::bad("No commands are allowed in the LOGOUT state.")
.map(|r| (r, flow::Transition::None))
.map_err(Error::msg)
}
};
// Process result
let res = match ctrl {
Ok((res, tr)) => {
//@FIXME remove unwrap
self.state = match self.state.apply(tr) {
Ok(new_state) => new_state,
Err(e) => {
tracing::error!("Invalid transition: {}, exiting", e);
break;
}
};
//@FIXME enrich here the command with some global status
Ok(res)
}
// Cast from anyhow::Error to Bal::Error
// @FIXME proper error handling would be great
Err(e) => match e.downcast::<BalError>() {
Ok(be) => Err(be),
Err(e) => {
tracing::warn!(error=%e, "internal.error");
Response::bad("Internal error")
}
},
};
//@FIXME I think we should quit this thread on error and having our manager watch it,
// and then abort the session as it is corrupted.
msg.tx.send(res).unwrap_or_else(|e| {
tracing::warn!("failed to send imap response to manager: {:#?}", e)
// Try to apply the transition and get the stop notifier
let maybe_stop = self
.state
.apply(transition)
.context("IDLE transition failed")
.and_then(|_| {
self.state
.notify()
.ok_or(anyhow!("IDLE state has no Notify object"))
});
if let flow::State::Logout = &self.state {
break;
// Build an appropriate response
match maybe_stop {
Ok(stop) => ResponseOrIdle::IdleAccept(stop),
Err(e) => {
tracing::error!(err=?e, "unable to init idle due to a transition error");
//ResponseOrIdle::IdleReject(tag)
let no = Response::build()
.tag(tag)
.message(
"Internal error, processing command triggered an illegal IMAP state transition",
)
.no()
.unwrap();
ResponseOrIdle::IdleReject(no)
}
}
}
//@FIXME add more info about the runner
tracing::debug!("exiting runner");
pub async fn idle_poll(&mut self) -> ResponseOrIdle {
match self.idle_poll_happy().await {
Ok(r) => r,
Err(e) => {
tracing::error!(err=?e, "something bad happened in idle");
ResponseOrIdle::Response(Response::bye().unwrap())
}
}
}
pub async fn idle_poll_happy(&mut self) -> Result<ResponseOrIdle> {
let (mbx, tag, stop) = match &mut self.state {
flow::State::Idle(_, ref mut mbx, _, tag, stop) => (mbx, tag.clone(), stop.clone()),
_ => bail!("Invalid session state, can't idle"),
};
tokio::select! {
_ = stop.notified() => {
self.state.apply(flow::Transition::UnIdle)?;
return Ok(ResponseOrIdle::Response(Response::build()
.tag(tag.clone())
.message("IDLE completed")
.ok()?))
},
change = mbx.idle_sync() => {
tracing::debug!("idle event");
return Ok(ResponseOrIdle::IdleEvent(change?));
}
}
}
pub async fn command(&mut self, cmd: Command<'static>) -> ResponseOrIdle {
// Command behavior is modulated by the state.
// To prevent state error, we handle the same command in separate code paths.
let (resp, tr) = match &mut self.state {
flow::State::NotAuthenticated => {
let ctx = anonymous::AnonymousContext {
req: &cmd,
login_provider: &self.login_provider,
server_capabilities: &self.server_capabilities,
};
anonymous::dispatch(ctx).await
}
flow::State::Authenticated(ref user) => {
let ctx = authenticated::AuthenticatedContext {
req: &cmd,
server_capabilities: &self.server_capabilities,
client_capabilities: &mut self.client_capabilities,
user,
};
authenticated::dispatch(ctx).await
}
flow::State::Selected(ref user, ref mut mailbox, ref perm) => {
let ctx = selected::SelectedContext {
req: &cmd,
server_capabilities: &self.server_capabilities,
client_capabilities: &mut self.client_capabilities,
user,
mailbox,
perm,
};
selected::dispatch(ctx).await
}
flow::State::Idle(..) => Err(anyhow!("can not receive command while idling")),
flow::State::Logout => Response::build()
.tag(cmd.tag.clone())
.message("No commands are allowed in the LOGOUT state.")
.bad()
.map(|r| (r, flow::Transition::None)),
}
.unwrap_or_else(|err| {
tracing::error!("Command error {:?} occured while processing {:?}", err, cmd);
(
Response::build()
.to_req(&cmd)
.message("Internal error while processing command")
.bad()
.unwrap(),
flow::Transition::None,
)
});
if let Err(e) = self.state.apply(tr) {
tracing::error!(
"Transition error {:?} occured while processing on command {:?}",
e,
cmd
);
return ResponseOrIdle::Response(Response::build()
.to_req(&cmd)
.message(
"Internal error, processing command triggered an illegal IMAP state transition",
)
.bad()
.unwrap());
}
ResponseOrIdle::Response(resp)
/*match &self.state {
flow::State::Idle(_, _, _, _, n) => ResponseOrIdle::StartIdle(n.clone()),
_ => ResponseOrIdle::Response(resp),
}*/
}
}

View file

@ -1,14 +1,10 @@
/*
use anyhow::Result;
use k2v_client::{CausalValue, CausalityToken, K2vClient};
// ---- UTIL: function to wait for a value to have changed in K2V ----
pub async fn k2v_wait_value_changed(
k2v: &K2vClient,
pk: &str,
sk: &str,
prev_ct: &Option<CausalityToken>,
k2v: &storage::RowStore,
key: &storage::RowRef,
) -> Result<CausalValue> {
loop {
if let Some(ct) = prev_ct {
@ -27,3 +23,4 @@ pub async fn k2v_wait_value_changed(
}
}
}
*/

View file

@ -16,7 +16,7 @@ use tokio::select;
use tokio::sync::watch;
use tokio_util::compat::*;
use smtp_message::{Email, EscapedDataReader, Reply, ReplyCode};
use smtp_message::{DataUnescaper, Email, EscapedDataReader, Reply, ReplyCode};
use smtp_server::{reply, Config, ConnectionMetadata, Decision, MailMetadata};
use crate::config::*;
@ -181,6 +181,12 @@ impl Config for LmtpServer {
return err_response_stream(meta, format!("io error: {}", e));
}
reader.complete();
let raw_size = text.len();
// Unescape email, shrink it also to remove last dot
let unesc_res = DataUnescaper::new(true).unescape(&mut text);
text.truncate(unesc_res.written);
tracing::debug!(prev_sz = raw_size, new_sz = text.len(), "unescaped");
let encrypted_message = match EncryptedMessage::new(text) {
Ok(x) => Arc::new(x),

View file

@ -0,0 +1,51 @@
use crate::login::*;
use crate::storage::*;
pub struct DemoLoginProvider {
keys: CryptoKeys,
in_memory_store: in_memory::MemDb,
}
impl DemoLoginProvider {
pub fn new() -> Self {
Self {
keys: CryptoKeys::init(),
in_memory_store: in_memory::MemDb::new(),
}
}
}
#[async_trait]
impl LoginProvider for DemoLoginProvider {
async fn login(&self, username: &str, password: &str) -> Result<Credentials> {
tracing::debug!(user=%username, "login");
if username != "alice" {
bail!("user does not exist");
}
if password != "hunter2" {
bail!("wrong password");
}
let storage = self.in_memory_store.builder("alice").await;
let keys = self.keys.clone();
Ok(Credentials { storage, keys })
}
async fn public_login(&self, email: &str) -> Result<PublicCredentials> {
tracing::debug!(user=%email, "public_login");
if email != "alice@example.tld" {
bail!("invalid email address");
}
let storage = self.in_memory_store.builder("alice").await;
let public_key = self.keys.public.clone();
Ok(PublicCredentials {
storage,
public_key,
})
}
}

View file

@ -5,10 +5,9 @@ use log::debug;
use crate::config::*;
use crate::login::*;
use crate::storage;
pub struct LdapLoginProvider {
k2v_region: Region,
s3_region: Region,
ldap_server: String,
pre_bind_on_login: bool,
@ -18,13 +17,11 @@ pub struct LdapLoginProvider {
attrs_to_retrieve: Vec<String>,
username_attr: String,
mail_attr: String,
crypto_root_attr: String,
aws_access_key_id_attr: String,
aws_secret_access_key_attr: String,
user_secret_attr: String,
alternate_user_secrets_attr: Option<String>,
bucket_source: BucketSource,
storage_specific: StorageSpecific,
in_memory_store: storage::in_memory::MemDb,
garage_store: storage::garage::GarageRoot,
}
enum BucketSource {
@ -32,8 +29,16 @@ enum BucketSource {
Attr(String),
}
enum StorageSpecific {
InMemory,
Garage {
from_config: LdapGarageConfig,
bucket_source: BucketSource,
},
}
impl LdapLoginProvider {
pub fn new(config: LoginLdapConfig, k2v_region: Region, s3_region: Region) -> Result<Self> {
pub fn new(config: LoginLdapConfig) -> Result<Self> {
let bind_dn_and_pw = match (config.bind_dn, config.bind_password) {
(Some(dn), Some(pw)) => Some((dn, pw)),
(None, None) => None,
@ -42,12 +47,6 @@ impl LdapLoginProvider {
),
};
let bucket_source = match (config.bucket, config.bucket_attr) {
(Some(b), None) => BucketSource::Constant(b),
(None, Some(a)) => BucketSource::Attr(a),
_ => bail!("Must set `bucket` or `bucket_attr`, but not both"),
};
if config.pre_bind_on_login && bind_dn_and_pw.is_none() {
bail!("Cannot use `pre_bind_on_login` without setting `bind_dn` and `bind_password`");
}
@ -55,20 +54,35 @@ impl LdapLoginProvider {
let mut attrs_to_retrieve = vec![
config.username_attr.clone(),
config.mail_attr.clone(),
config.aws_access_key_id_attr.clone(),
config.aws_secret_access_key_attr.clone(),
config.user_secret_attr.clone(),
config.crypto_root_attr.clone(),
];
if let Some(a) = &config.alternate_user_secrets_attr {
attrs_to_retrieve.push(a.clone());
}
if let BucketSource::Attr(a) = &bucket_source {
attrs_to_retrieve.push(a.clone());
}
// storage specific
let specific = match config.storage {
LdapStorage::InMemory => StorageSpecific::InMemory,
LdapStorage::Garage(grgconf) => {
attrs_to_retrieve.push(grgconf.aws_access_key_id_attr.clone());
attrs_to_retrieve.push(grgconf.aws_secret_access_key_attr.clone());
let bucket_source =
match (grgconf.default_bucket.clone(), grgconf.bucket_attr.clone()) {
(Some(b), None) => BucketSource::Constant(b),
(None, Some(a)) => BucketSource::Attr(a),
_ => bail!("Must set `bucket` or `bucket_attr`, but not both"),
};
if let BucketSource::Attr(a) = &bucket_source {
attrs_to_retrieve.push(a.clone());
}
StorageSpecific::Garage {
from_config: grgconf,
bucket_source,
}
}
};
Ok(Self {
k2v_region,
s3_region,
ldap_server: config.ldap_server,
pre_bind_on_login: config.pre_bind_on_login,
bind_dn_and_pw,
@ -76,29 +90,47 @@ impl LdapLoginProvider {
attrs_to_retrieve,
username_attr: config.username_attr,
mail_attr: config.mail_attr,
aws_access_key_id_attr: config.aws_access_key_id_attr,
aws_secret_access_key_attr: config.aws_secret_access_key_attr,
user_secret_attr: config.user_secret_attr,
alternate_user_secrets_attr: config.alternate_user_secrets_attr,
bucket_source,
crypto_root_attr: config.crypto_root_attr,
storage_specific: specific,
//@FIXME should be created outside of the login provider
//Login provider should return only a cryptoroot + a storage URI
//storage URI that should be resolved outside...
in_memory_store: storage::in_memory::MemDb::new(),
garage_store: storage::garage::GarageRoot::new()?,
})
}
fn storage_creds_from_ldap_user(&self, user: &SearchEntry) -> Result<StorageCredentials> {
let aws_access_key_id = get_attr(user, &self.aws_access_key_id_attr)?;
let aws_secret_access_key = get_attr(user, &self.aws_secret_access_key_attr)?;
let bucket = match &self.bucket_source {
BucketSource::Constant(b) => b.clone(),
BucketSource::Attr(a) => get_attr(user, a)?,
async fn storage_creds_from_ldap_user(&self, user: &SearchEntry) -> Result<Builder> {
let storage: Builder = match &self.storage_specific {
StorageSpecific::InMemory => {
self.in_memory_store
.builder(&get_attr(user, &self.username_attr)?)
.await
}
StorageSpecific::Garage {
from_config,
bucket_source,
} => {
let aws_access_key_id = get_attr(user, &from_config.aws_access_key_id_attr)?;
let aws_secret_access_key =
get_attr(user, &from_config.aws_secret_access_key_attr)?;
let bucket = match bucket_source {
BucketSource::Constant(b) => b.clone(),
BucketSource::Attr(a) => get_attr(user, &a)?,
};
self.garage_store.user(storage::garage::GarageConf {
region: from_config.aws_region.clone(),
s3_endpoint: from_config.s3_endpoint.clone(),
k2v_endpoint: from_config.k2v_endpoint.clone(),
aws_access_key_id,
aws_secret_access_key,
bucket,
})?
}
};
Ok(StorageCredentials {
k2v_region: self.k2v_region.clone(),
s3_region: self.s3_region.clone(),
aws_access_key_id,
aws_secret_access_key,
bucket,
})
Ok(storage)
}
}
@ -148,22 +180,16 @@ impl LoginProvider for LdapLoginProvider {
.context("Invalid password")?;
debug!("Ldap login with user name {} successfull", username);
let storage = self.storage_creds_from_ldap_user(&user)?;
// cryptography
let crstr = get_attr(&user, &self.crypto_root_attr)?;
let cr = CryptoRoot(crstr);
let keys = cr.crypto_keys(password)?;
let user_secret = get_attr(&user, &self.user_secret_attr)?;
let alternate_user_secrets = match &self.alternate_user_secrets_attr {
None => vec![],
Some(a) => user.attrs.get(a).cloned().unwrap_or_default(),
};
let user_secrets = UserSecrets {
user_secret,
alternate_user_secrets,
};
// storage
let storage = self.storage_creds_from_ldap_user(&user).await?;
drop(ldap);
let keys = CryptoKeys::open(&storage, &user_secrets, password).await?;
Ok(Credentials { storage, keys })
}
@ -201,11 +227,14 @@ impl LoginProvider for LdapLoginProvider {
let user = SearchEntry::construct(matches.into_iter().next().unwrap());
debug!("Found matching LDAP user for email {}: {}", email, user.dn);
let storage = self.storage_creds_from_ldap_user(&user)?;
drop(ldap);
// cryptography
let crstr = get_attr(&user, &self.crypto_root_attr)?;
let cr = CryptoRoot(crstr);
let public_key = cr.public_key()?;
let k2v_client = storage.k2v_client()?;
let (_, public_key) = CryptoKeys::load_salt_and_public(&k2v_client).await?;
// storage
let storage = self.storage_creds_from_ldap_user(&user).await?;
drop(ldap);
Ok(PublicCredentials {
storage,

View file

@ -1,20 +1,16 @@
pub mod demo_provider;
pub mod ldap_provider;
pub mod static_provider;
use std::collections::BTreeMap;
use base64::Engine;
use std::sync::Arc;
use anyhow::{anyhow, bail, Context, Result};
use async_trait::async_trait;
use k2v_client::{
BatchInsertOp, BatchReadOp, CausalValue, CausalityToken, Filter, K2vClient, K2vValue
};
use rand::prelude::*;
use rusoto_core::HttpClient;
use rusoto_credential::{AwsCredentials, StaticProvider};
use rusoto_s3::S3Client;
use crate::cryptoblob::*;
use crate::storage::*;
/// The trait LoginProvider defines the interface for a login provider that allows
/// to retrieve storage and cryptographic credentials for access to a user account
@ -38,7 +34,7 @@ pub type ArcLoginProvider = Arc<dyn LoginProvider + Send + Sync>;
#[derive(Clone, Debug)]
pub struct Credentials {
/// The storage credentials are used to authenticate access to the underlying storage (S3, K2V)
pub storage: StorageCredentials,
pub storage: Builder,
/// The cryptographic keys are used to encrypt and decrypt data stored in S3 and K2V
pub keys: CryptoKeys,
}
@ -46,32 +42,93 @@ pub struct Credentials {
#[derive(Clone, Debug)]
pub struct PublicCredentials {
/// The storage credentials are used to authenticate access to the underlying storage (S3, K2V)
pub storage: StorageCredentials,
pub storage: Builder,
pub public_key: PublicKey,
}
/// The struct StorageCredentials contains access key to an S3 and K2V bucket
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct StorageCredentials {
pub s3_region: Region,
pub k2v_region: Region,
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CryptoRoot(pub String);
pub aws_access_key_id: String,
pub aws_secret_access_key: String,
pub bucket: String,
}
impl CryptoRoot {
pub fn create_pass(password: &str, k: &CryptoKeys) -> Result<Self> {
let bytes = k.password_seal(password)?;
let b64 = base64::engine::general_purpose::STANDARD_NO_PAD.encode(bytes);
let cr = format!("aero:cryptoroot:pass:{}", b64);
Ok(Self(cr))
}
/// The struct UserSecrets represents intermediary secrets that are mixed in with the user's
/// password when decrypting the cryptographic keys that are stored in their bucket.
/// These secrets should be stored somewhere else (e.g. in the LDAP server or in the
/// local config file), as an additionnal authentification factor so that the password
/// isn't enough just alone to decrypt the content of a user's bucket.
pub struct UserSecrets {
/// The main user secret that will be used to encrypt keys when a new password is added
pub user_secret: String,
/// Alternative user secrets that will be tried when decrypting keys that were encrypted
/// with old passwords
pub alternate_user_secrets: Vec<String>,
pub fn create_cleartext(k: &CryptoKeys) -> Self {
let bytes = k.serialize();
let b64 = base64::engine::general_purpose::STANDARD_NO_PAD.encode(bytes);
let cr = format!("aero:cryptoroot:cleartext:{}", b64);
Self(cr)
}
pub fn create_incoming(pk: &PublicKey) -> Self {
let bytes: &[u8] = &pk[..];
let b64 = base64::engine::general_purpose::STANDARD_NO_PAD.encode(bytes);
let cr = format!("aero:cryptoroot:incoming:{}", b64);
Self(cr)
}
pub fn public_key(&self) -> Result<PublicKey> {
match self.0.splitn(4, ':').collect::<Vec<&str>>()[..] {
["aero", "cryptoroot", "pass", b64blob] => {
let blob = base64::engine::general_purpose::STANDARD_NO_PAD.decode(b64blob)?;
if blob.len() < 32 {
bail!(
"Decoded data is {} bytes long, expect at least 32 bytes",
blob.len()
);
}
PublicKey::from_slice(&blob[..32]).context("must be a valid public key")
}
["aero", "cryptoroot", "cleartext", b64blob] => {
let blob = base64::engine::general_purpose::STANDARD_NO_PAD.decode(b64blob)?;
Ok(CryptoKeys::deserialize(&blob)?.public)
}
["aero", "cryptoroot", "incoming", b64blob] => {
let blob = base64::engine::general_purpose::STANDARD_NO_PAD.decode(b64blob)?;
if blob.len() < 32 {
bail!(
"Decoded data is {} bytes long, expect at least 32 bytes",
blob.len()
);
}
PublicKey::from_slice(&blob[..32]).context("must be a valid public key")
}
["aero", "cryptoroot", "keyring", _] => {
bail!("keyring is not yet implemented!")
}
_ => bail!(format!(
"passed string '{}' is not a valid cryptoroot",
self.0
)),
}
}
pub fn crypto_keys(&self, password: &str) -> Result<CryptoKeys> {
match self.0.splitn(4, ':').collect::<Vec<&str>>()[..] {
["aero", "cryptoroot", "pass", b64blob] => {
let blob = base64::engine::general_purpose::STANDARD_NO_PAD.decode(b64blob)?;
CryptoKeys::password_open(password, &blob)
}
["aero", "cryptoroot", "cleartext", b64blob] => {
let blob = base64::engine::general_purpose::STANDARD_NO_PAD.decode(b64blob)?;
CryptoKeys::deserialize(&blob)
}
["aero", "cryptoroot", "incoming", _] => {
bail!("incoming cryptoroot does not contain a crypto key!")
}
["aero", "cryptoroot", "keyring", _] => {
bail!("keyring is not yet implemented!")
}
_ => bail!(format!(
"passed string '{}' is not a valid cryptoroot",
self.0
)),
}
}
}
/// The struct CryptoKeys contains the cryptographic keys used to encrypt and decrypt
@ -86,426 +143,22 @@ pub struct CryptoKeys {
pub public: PublicKey,
}
/// A custom S3 region, composed of a region name and endpoint.
/// We use this instead of rusoto_signature::Region so that we can
/// derive Hash and Eq
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct Region {
pub name: String,
pub endpoint: String,
}
impl Region {
pub fn as_rusoto_region(&self) -> rusoto_signature::Region {
rusoto_signature::Region::Custom {
name: self.name.clone(),
endpoint: self.endpoint.clone(),
}
}
}
// ----
impl Credentials {
pub fn k2v_client(&self) -> Result<K2vClient> {
self.storage.k2v_client()
}
pub fn s3_client(&self) -> Result<S3Client> {
self.storage.s3_client()
}
pub fn bucket(&self) -> &str {
self.storage.bucket.as_str()
}
}
impl StorageCredentials {
pub fn k2v_client(&self) -> Result<K2vClient> {
let aws_creds = AwsCredentials::new(
self.aws_access_key_id.clone(),
self.aws_secret_access_key.clone(),
None,
None,
);
Ok(K2vClient::new(
self.k2v_region.as_rusoto_region(),
self.bucket.clone(),
aws_creds,
None,
)?)
}
pub fn s3_client(&self) -> Result<S3Client> {
let aws_creds_provider = StaticProvider::new_minimal(
self.aws_access_key_id.clone(),
self.aws_secret_access_key.clone(),
);
let connector = hyper_rustls::HttpsConnectorBuilder::new()
.with_native_roots()
.https_or_http()
.enable_http1()
.enable_http2()
.build();
let client = HttpClient::from_connector(connector);
Ok(S3Client::new_with(
client,
aws_creds_provider,
self.s3_region.as_rusoto_region(),
))
}
}
impl CryptoKeys {
pub async fn init(
storage: &StorageCredentials,
user_secrets: &UserSecrets,
password: &str,
) -> Result<Self> {
// Check that salt and public don't exist already
let k2v = storage.k2v_client()?;
let (salt_ct, public_ct) = Self::check_uninitialized(&k2v).await?;
// Generate salt for password identifiers
let mut ident_salt = [0u8; 32];
thread_rng().fill(&mut ident_salt);
// Generate (public, private) key pair and master key
/// Initialize a new cryptography root
pub fn init() -> Self {
let (public, secret) = gen_keypair();
let master = gen_key();
let keys = CryptoKeys {
CryptoKeys {
master,
secret,
public,
};
// Generate short password digest (= password identity)
let ident = argon2_kdf(&ident_salt, password.as_bytes(), 16)?;
// Generate salt for KDF
let mut kdf_salt = [0u8; 32];
thread_rng().fill(&mut kdf_salt);
// Calculate key for password secret box
let password_key = user_secrets.derive_password_key(&kdf_salt, password)?;
// Seal a secret box that contains our crypto keys
let password_sealed = seal(&keys.serialize(), &password_key)?;
let password_sortkey = format!("password:{}", hex::encode(&ident));
let password_blob = [&kdf_salt[..], &password_sealed].concat();
// Write values to storage
k2v.insert_batch(&[
k2v_insert_single_key("keys", "salt", salt_ct, ident_salt),
k2v_insert_single_key("keys", "public", public_ct, keys.public),
k2v_insert_single_key("keys", &password_sortkey, None, &password_blob),
])
.await
.context("InsertBatch for salt, public, and password")?;
Ok(keys)
}
pub async fn init_without_password(
storage: &StorageCredentials,
master: &Key,
secret: &SecretKey,
) -> Result<Self> {
// Check that salt and public don't exist already
let k2v = storage.k2v_client()?;
let (salt_ct, public_ct) = Self::check_uninitialized(&k2v).await?;
// Generate salt for password identifiers
let mut ident_salt = [0u8; 32];
thread_rng().fill(&mut ident_salt);
// Create CryptoKeys struct from given keys
let public = secret.public_key();
let keys = CryptoKeys {
master: master.clone(),
secret: secret.clone(),
public,
};
// Write values to storage
k2v.insert_batch(&[
k2v_insert_single_key("keys", "salt", salt_ct, ident_salt),
k2v_insert_single_key("keys", "public", public_ct, keys.public),
])
.await
.context("InsertBatch for salt and public")?;
Ok(keys)
}
pub async fn open(
storage: &StorageCredentials,
user_secrets: &UserSecrets,
password: &str,
) -> Result<Self> {
let k2v = storage.k2v_client()?;
let (ident_salt, expected_public) = Self::load_salt_and_public(&k2v).await?;
// Generate short password digest (= password identity)
let ident = argon2_kdf(&ident_salt, password.as_bytes(), 16)?;
// Lookup password blob
let password_sortkey = format!("password:{}", hex::encode(&ident));
let password_blob = {
let mut val = match k2v.read_item("keys", &password_sortkey).await {
Err(k2v_client::Error::NotFound) => {
bail!("invalid password")
}
x => x?,
};
if val.value.len() != 1 {
bail!("multiple values for password in storage");
}
match val.value.pop().unwrap() {
K2vValue::Value(v) => v,
K2vValue::Tombstone => bail!("invalid password"),
}
};
// Try to open blob
let kdf_salt = &password_blob[..32];
let password_openned =
user_secrets.try_open_encrypted_keys(kdf_salt, password, &password_blob[32..])?;
let keys = Self::deserialize(&password_openned)?;
if keys.public != expected_public {
bail!("Password public key doesn't match stored public key");
}
Ok(keys)
}
pub async fn open_without_password(
storage: &StorageCredentials,
master: &Key,
secret: &SecretKey,
) -> Result<Self> {
let k2v = storage.k2v_client()?;
let (_ident_salt, expected_public) = Self::load_salt_and_public(&k2v).await?;
// Create CryptoKeys struct from given keys
let public = secret.public_key();
let keys = CryptoKeys {
master: master.clone(),
secret: secret.clone(),
public,
};
// Check public key matches
if keys.public != expected_public {
bail!("Given public key doesn't match stored public key");
}
Ok(keys)
}
pub async fn add_password(
&self,
storage: &StorageCredentials,
user_secrets: &UserSecrets,
password: &str,
) -> Result<()> {
let k2v = storage.k2v_client()?;
let (ident_salt, _public) = Self::load_salt_and_public(&k2v).await?;
// Generate short password digest (= password identity)
let ident = argon2_kdf(&ident_salt, password.as_bytes(), 16)?;
// Generate salt for KDF
let mut kdf_salt = [0u8; 32];
thread_rng().fill(&mut kdf_salt);
// Calculate key for password secret box
let password_key = user_secrets.derive_password_key(&kdf_salt, password)?;
// Seal a secret box that contains our crypto keys
let password_sealed = seal(&self.serialize(), &password_key)?;
let password_sortkey = format!("password:{}", hex::encode(&ident));
let password_blob = [&kdf_salt[..], &password_sealed].concat();
// List existing passwords to overwrite existing entry if necessary
let ct = match k2v.read_item("keys", &password_sortkey).await {
Err(k2v_client::Error::NotFound) => None,
v => {
let entry = v?;
if entry.value.iter().any(|x| matches!(x, K2vValue::Value(_))) {
bail!("password already exists");
}
Some(entry.causality)
}
};
// Write values to storage
k2v.insert_batch(&[k2v_insert_single_key(
"keys",
&password_sortkey,
ct,
&password_blob,
)])
.await
.context("InsertBatch for new password")?;
Ok(())
}
pub async fn delete_password(
storage: &StorageCredentials,
password: &str,
allow_delete_all: bool,
) -> Result<()> {
let k2v = storage.k2v_client()?;
let (ident_salt, _public) = Self::load_salt_and_public(&k2v).await?;
// Generate short password digest (= password identity)
let ident = argon2_kdf(&ident_salt, password.as_bytes(), 16)?;
let password_sortkey = format!("password:{}", hex::encode(&ident));
// List existing passwords
let existing_passwords = Self::list_existing_passwords(&k2v).await?;
// Check password is there
let pw = existing_passwords
.get(&password_sortkey)
.ok_or(anyhow!("password does not exist"))?;
if !allow_delete_all && existing_passwords.len() < 2 {
bail!("No other password exists, not deleting last password.");
}
k2v.delete_item("keys", &password_sortkey, pw.causality.clone())
.await
.context("DeleteItem for password")?;
Ok(())
}
// ---- STORAGE UTIL ----
async fn check_uninitialized(
k2v: &K2vClient,
) -> Result<(Option<CausalityToken>, Option<CausalityToken>)> {
let params = k2v
.read_batch(&[
k2v_read_single_key("keys", "salt", true),
k2v_read_single_key("keys", "public", true),
])
.await
.context("ReadBatch for salt and public in check_uninitialized")?;
if params.len() != 2 {
bail!(
"Invalid response from k2v storage: {:?} (expected two items)",
params
);
}
if params[0].items.len() > 1 || params[1].items.len() > 1 {
bail!(
"invalid response from k2v storage: {:?} (several items in single_item read)",
params
);
}
let salt_ct = match params[0].items.iter().next() {
None => None,
Some((_, CausalValue { causality, value })) => {
if value.iter().any(|x| matches!(x, K2vValue::Value(_))) {
bail!("key storage already initialized");
}
Some(causality.clone())
}
};
let public_ct = match params[1].items.iter().next() {
None => None,
Some((_, CausalValue { causality, value })) => {
if value.iter().any(|x| matches!(x, K2vValue::Value(_))) {
bail!("key storage already initialized");
}
Some(causality.clone())
}
};
Ok((salt_ct, public_ct))
}
pub async fn load_salt_and_public(k2v: &K2vClient) -> Result<([u8; 32], PublicKey)> {
let mut params = k2v
.read_batch(&[
k2v_read_single_key("keys", "salt", false),
k2v_read_single_key("keys", "public", false),
])
.await
.context("ReadBatch for salt and public in load_salt_and_public")?;
if params.len() != 2 {
bail!(
"Invalid response from k2v storage: {:?} (expected two items)",
params
);
}
if params[0].items.len() != 1 || params[1].items.len() != 1 {
bail!("cryptographic keys not initialized for user");
}
// Retrieve salt from given response
let salt_vals = &mut params[0].items.iter_mut().next().unwrap().1.value;
if salt_vals.len() != 1 {
bail!("Multiple values for `salt`");
}
let salt: Vec<u8> = match &mut salt_vals[0] {
K2vValue::Value(v) => std::mem::take(v),
K2vValue::Tombstone => bail!("salt is a tombstone"),
};
if salt.len() != 32 {
bail!("`salt` is not 32 bytes long");
}
let mut salt_constlen = [0u8; 32];
salt_constlen.copy_from_slice(&salt);
// Retrieve public from given response
let public_vals = &mut params[1].items.iter_mut().next().unwrap().1.value;
if public_vals.len() != 1 {
bail!("Multiple values for `public`");
}
let public: Vec<u8> = match &mut public_vals[0] {
K2vValue::Value(v) => std::mem::take(v),
K2vValue::Tombstone => bail!("public is a tombstone"),
};
let public = PublicKey::from_slice(&public).ok_or(anyhow!("Invalid public key length"))?;
Ok((salt_constlen, public))
}
async fn list_existing_passwords(k2v: &K2vClient) -> Result<BTreeMap<String, CausalValue>> {
let mut res = k2v
.read_batch(&[BatchReadOp {
partition_key: "keys",
filter: Filter {
start: None,
end: None,
prefix: Some("password:"),
limit: None,
reverse: false,
},
conflicts_only: false,
tombstones: false,
single_item: false,
}])
.await
.context("ReadBatch for prefix password: in list_existing_passwords")?;
if res.len() != 1 {
bail!("unexpected k2v result: {:?}, expected one item", res);
}
Ok(res.pop().unwrap().items)
}
}
// Clear text serialize/deserialize
/// Serialize the root as bytes without encryption
fn serialize(&self) -> [u8; 64] {
let mut res = [0u8; 64];
res[..32].copy_from_slice(self.master.as_ref());
@ -513,6 +166,7 @@ impl CryptoKeys {
res
}
/// Deserialize a clear text crypto root without encryption
fn deserialize(bytes: &[u8]) -> Result<Self> {
if bytes.len() != 64 {
bail!("Invalid length: {}, expected 64", bytes.len());
@ -526,91 +180,66 @@ impl CryptoKeys {
public,
})
}
// Password sealed keys serialize/deserialize
pub fn password_open(password: &str, blob: &[u8]) -> Result<Self> {
let _pubkey = &blob[0..32];
let kdf_salt = &blob[32..64];
let password_openned = try_open_encrypted_keys(kdf_salt, password, &blob[64..])?;
let keys = Self::deserialize(&password_openned)?;
Ok(keys)
}
pub fn password_seal(&self, password: &str) -> Result<Vec<u8>> {
let mut kdf_salt = [0u8; 32];
thread_rng().fill(&mut kdf_salt);
// Calculate key for password secret box
let password_key = derive_password_key(&kdf_salt, password)?;
// Seal a secret box that contains our crypto keys
let password_sealed = seal(&self.serialize(), &password_key)?;
// Create blob
let password_blob = [&self.public[..], &kdf_salt[..], &password_sealed].concat();
Ok(password_blob)
}
}
impl UserSecrets {
fn derive_password_key_with(user_secret: &str, kdf_salt: &[u8], password: &str) -> Result<Key> {
let tmp = format!("{}\n\n{}", user_secret, password);
Ok(Key::from_slice(&argon2_kdf(kdf_salt, tmp.as_bytes(), 32)?).unwrap())
}
fn derive_password_key(kdf_salt: &[u8], password: &str) -> Result<Key> {
Ok(Key::from_slice(&argon2_kdf(kdf_salt, password.as_bytes(), 32)?).unwrap())
}
fn derive_password_key(&self, kdf_salt: &[u8], password: &str) -> Result<Key> {
Self::derive_password_key_with(&self.user_secret, kdf_salt, password)
}
fn try_open_encrypted_keys(
&self,
kdf_salt: &[u8],
password: &str,
encrypted_keys: &[u8],
) -> Result<Vec<u8>> {
let secrets_to_try =
std::iter::once(&self.user_secret).chain(self.alternate_user_secrets.iter());
for user_secret in secrets_to_try {
let password_key = Self::derive_password_key_with(user_secret, kdf_salt, password)?;
if let Ok(res) = open(encrypted_keys, &password_key) {
return Ok(res);
}
}
bail!("Unable to decrypt password blob.");
}
fn try_open_encrypted_keys(
kdf_salt: &[u8],
password: &str,
encrypted_keys: &[u8],
) -> Result<Vec<u8>> {
let password_key = derive_password_key(kdf_salt, password)?;
open(encrypted_keys, &password_key)
}
// ---- UTIL ----
pub fn argon2_kdf(salt: &[u8], password: &[u8], output_len: usize) -> Result<Vec<u8>> {
use argon2::{Algorithm, Argon2, ParamsBuilder, PasswordHasher, Version};
use argon2::{password_hash, Algorithm, Argon2, ParamsBuilder, PasswordHasher, Version};
let mut params = ParamsBuilder::new();
params
let params = ParamsBuilder::new()
.output_len(output_len)
.map_err(|e| anyhow!("Invalid output length: {}", e))?;
let params = params
.params()
.build()
.map_err(|e| anyhow!("Invalid argon2 params: {}", e))?;
let argon2 = Argon2::new(Algorithm::default(), Version::default(), params);
let salt = base64::encode_config(salt, base64::STANDARD_NO_PAD);
let b64_salt = base64::engine::general_purpose::STANDARD_NO_PAD.encode(salt);
let valid_salt = password_hash::Salt::from_b64(&b64_salt)
.map_err(|e| anyhow!("Invalid salt, error {}", e))?;
let hash = argon2
.hash_password(password, &salt)
.hash_password(password, valid_salt)
.map_err(|e| anyhow!("Unable to hash: {}", e))?;
let hash = hash.hash.ok_or(anyhow!("Missing output"))?;
assert!(hash.len() == output_len);
Ok(hash.as_bytes().to_vec())
}
pub fn k2v_read_single_key<'a>(
partition_key: &'a str,
sort_key: &'a str,
tombstones: bool,
) -> BatchReadOp<'a> {
BatchReadOp {
partition_key,
filter: Filter {
start: Some(sort_key),
end: None,
prefix: None,
limit: None,
reverse: false,
},
conflicts_only: false,
tombstones,
single_item: true,
}
}
pub fn k2v_insert_single_key<'a>(
partition_key: &'a str,
sort_key: &'a str,
causality: Option<CausalityToken>,
value: impl AsRef<[u8]>,
) -> BatchInsertOp<'a> {
BatchInsertOp {
partition_key,
sort_key,
causality,
value: K2vValue::Value(value.as_ref().to_vec()),
}
}

View file

@ -1,45 +1,91 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::signal::unix::{signal, SignalKind};
use tokio::sync::watch;
use anyhow::{anyhow, bail, Result};
use async_trait::async_trait;
use crate::config::*;
use crate::cryptoblob::{Key, SecretKey};
use crate::login::*;
use crate::storage;
pub struct StaticLoginProvider {
default_bucket: Option<String>,
users: HashMap<String, Arc<LoginStaticUser>>,
users_by_email: HashMap<String, Arc<LoginStaticUser>>,
k2v_region: Region,
s3_region: Region,
pub struct ContextualUserEntry {
pub username: String,
pub config: UserEntry,
}
impl StaticLoginProvider {
pub fn new(config: LoginStaticConfig, k2v_region: Region, s3_region: Region) -> Result<Self> {
let users = config
.users
#[derive(Default)]
pub struct UserDatabase {
users: HashMap<String, Arc<ContextualUserEntry>>,
users_by_email: HashMap<String, Arc<ContextualUserEntry>>,
}
pub struct StaticLoginProvider {
user_db: watch::Receiver<UserDatabase>,
in_memory_store: storage::in_memory::MemDb,
garage_store: storage::garage::GarageRoot,
}
pub async fn update_user_list(config: PathBuf, up: watch::Sender<UserDatabase>) -> Result<()> {
let mut stream = signal(SignalKind::user_defined1())
.expect("failed to install SIGUSR1 signal hander for reload");
loop {
let ulist: UserList = match read_config(config.clone()) {
Ok(x) => x,
Err(e) => {
tracing::warn!(path=%config.as_path().to_string_lossy(), error=%e, "Unable to load config");
stream.recv().await;
continue;
}
};
let users = ulist
.into_iter()
.map(|(k, v)| (k, Arc::new(v)))
.map(|(username, config)| {
(
username.clone(),
Arc::new(ContextualUserEntry { username, config }),
)
})
.collect::<HashMap<_, _>>();
let mut users_by_email = HashMap::new();
for (_, u) in users.iter() {
for m in u.email_addresses.iter() {
for m in u.config.email_addresses.iter() {
if users_by_email.contains_key(m) {
bail!("Several users have same email address: {}", m);
tracing::warn!("Several users have the same email address: {}", m);
stream.recv().await;
continue;
}
users_by_email.insert(m.clone(), u.clone());
}
}
Ok(Self {
default_bucket: config.default_bucket,
tracing::info!("{} users loaded", users.len());
up.send(UserDatabase {
users,
users_by_email,
k2v_region,
s3_region,
})
.context("update user db config")?;
stream.recv().await;
tracing::info!("Received SIGUSR1, reloading");
}
}
impl StaticLoginProvider {
pub async fn new(config: LoginStaticConfig) -> Result<Self> {
let (tx, mut rx) = watch::channel(UserDatabase::default());
tokio::spawn(update_user_list(config.user_list, tx));
rx.changed().await?;
Ok(Self {
user_db: rx,
in_memory_store: storage::in_memory::MemDb::new(),
garage_store: storage::garage::GarageRoot::new()?,
})
}
}
@ -48,82 +94,67 @@ impl StaticLoginProvider {
impl LoginProvider for StaticLoginProvider {
async fn login(&self, username: &str, password: &str) -> Result<Credentials> {
tracing::debug!(user=%username, "login");
let user = match self.users.get(username) {
None => bail!("User {} does not exist", username),
Some(u) => u,
let user = {
let user_db = self.user_db.borrow();
match user_db.users.get(username) {
None => bail!("User {} does not exist", username),
Some(u) => u.clone(),
}
};
tracing::debug!(user=%username, "verify password");
if !verify_password(password, &user.password)? {
if !verify_password(password, &user.config.password)? {
bail!("Wrong password");
}
tracing::debug!(user=%username, "fetch bucket");
let bucket = user
.bucket
.clone()
.or_else(|| self.default_bucket.clone())
.ok_or(anyhow!(
"No bucket configured and no default bucket specieid"
))?;
tracing::debug!(user=%username, "fetch keys");
let storage = StorageCredentials {
k2v_region: self.k2v_region.clone(),
s3_region: self.s3_region.clone(),
aws_access_key_id: user.aws_access_key_id.clone(),
aws_secret_access_key: user.aws_secret_access_key.clone(),
bucket,
let storage: storage::Builder = match &user.config.storage {
StaticStorage::InMemory => self.in_memory_store.builder(username).await,
StaticStorage::Garage(grgconf) => {
self.garage_store.user(storage::garage::GarageConf {
region: grgconf.aws_region.clone(),
k2v_endpoint: grgconf.k2v_endpoint.clone(),
s3_endpoint: grgconf.s3_endpoint.clone(),
aws_access_key_id: grgconf.aws_access_key_id.clone(),
aws_secret_access_key: grgconf.aws_secret_access_key.clone(),
bucket: grgconf.bucket.clone(),
})?
}
};
let keys = match (&user.master_key, &user.secret_key) {
(Some(m), Some(s)) => {
let master_key =
Key::from_slice(&base64::decode(m)?).ok_or(anyhow!("Invalid master key"))?;
let secret_key = SecretKey::from_slice(&base64::decode(s)?)
.ok_or(anyhow!("Invalid secret key"))?;
CryptoKeys::open_without_password(&storage, &master_key, &secret_key).await?
}
(None, None) => {
let user_secrets = UserSecrets {
user_secret: user.user_secret.clone(),
alternate_user_secrets: user.alternate_user_secrets.clone(),
};
CryptoKeys::open(&storage, &user_secrets, password).await?
}
_ => bail!(
"Either both master and secret key or none of them must be specified for user"
),
};
let cr = CryptoRoot(user.config.crypto_root.clone());
let keys = cr.crypto_keys(password)?;
tracing::debug!(user=%username, "logged");
Ok(Credentials { storage, keys })
}
async fn public_login(&self, email: &str) -> Result<PublicCredentials> {
let user = match self.users_by_email.get(email) {
None => bail!("No user for email address {}", email),
Some(u) => u,
let user = {
let user_db = self.user_db.borrow();
match user_db.users_by_email.get(email) {
None => bail!("Email {} does not exist", email),
Some(u) => u.clone(),
}
};
tracing::debug!(user=%user.username, "public_login");
let storage: storage::Builder = match &user.config.storage {
StaticStorage::InMemory => self.in_memory_store.builder(&user.username).await,
StaticStorage::Garage(grgconf) => {
self.garage_store.user(storage::garage::GarageConf {
region: grgconf.aws_region.clone(),
k2v_endpoint: grgconf.k2v_endpoint.clone(),
s3_endpoint: grgconf.s3_endpoint.clone(),
aws_access_key_id: grgconf.aws_access_key_id.clone(),
aws_secret_access_key: grgconf.aws_secret_access_key.clone(),
bucket: grgconf.bucket.clone(),
})?
}
};
let bucket = user
.bucket
.clone()
.or_else(|| self.default_bucket.clone())
.ok_or(anyhow!(
"No bucket configured and no default bucket specieid"
))?;
let storage = StorageCredentials {
k2v_region: self.k2v_region.clone(),
s3_region: self.s3_region.clone(),
aws_access_key_id: user.aws_access_key_id.clone(),
aws_secret_access_key: user.aws_secret_access_key.clone(),
bucket,
};
let k2v_client = storage.k2v_client()?;
let (_, public_key) = CryptoKeys::load_salt_and_public(&k2v_client).await?;
let cr = CryptoRoot(user.config.crypto_root.clone());
let public_key = cr.public_key()?;
Ok(PublicCredentials {
storage,

View file

@ -1,28 +1,25 @@
use std::collections::HashMap;
//use std::collections::HashMap;
use std::convert::TryFrom;
use std::sync::{Arc, Weak};
use std::time::Duration;
use anyhow::{anyhow, bail, Result};
use base64::Engine;
use futures::{future::BoxFuture, FutureExt};
use k2v_client::{CausalityToken, K2vClient, K2vValue};
use rusoto_s3::{
DeleteObjectRequest, GetObjectRequest, ListObjectsV2Request, PutObjectRequest, S3Client, S3,
};
use tokio::io::AsyncReadExt;
//use tokio::io::AsyncReadExt;
use tokio::sync::watch;
use tracing::{error, info, warn};
use tracing::{debug, error, info, warn};
use crate::cryptoblob;
use crate::k2v_util::k2v_wait_value_changed;
use crate::login::{Credentials, PublicCredentials};
use crate::mail::mailbox::Mailbox;
use crate::mail::uidindex::ImapUidvalidity;
use crate::mail::unique_ident::*;
use crate::mail::user::User;
use crate::mail::IMF;
use crate::time::now_msec;
use crate::storage;
use crate::timestamp::now_msec;
const INCOMING_PK: &str = "incoming";
const INCOMING_LOCK_SK: &str = "lock";
@ -54,24 +51,23 @@ async fn incoming_mail_watch_process_internal(
creds: Credentials,
mut rx_inbox_id: watch::Receiver<Option<(UniqueIdent, ImapUidvalidity)>>,
) -> Result<()> {
let mut lock_held = k2v_lock_loop(creds.k2v_client()?, INCOMING_PK, INCOMING_LOCK_SK);
let k2v = creds.k2v_client()?;
let s3 = creds.s3_client()?;
let mut lock_held = k2v_lock_loop(
creds.storage.build().await?,
storage::RowRef::new(INCOMING_PK, INCOMING_LOCK_SK),
);
let storage = creds.storage.build().await?;
let mut inbox: Option<Arc<Mailbox>> = None;
let mut prev_ct: Option<CausalityToken> = None;
let mut incoming_key = storage::RowRef::new(INCOMING_PK, INCOMING_WATCH_SK);
loop {
let new_mail = if *lock_held.borrow() {
info!("incoming lock held");
let maybe_updated_incoming_key = if *lock_held.borrow() {
debug!("incoming lock held");
let wait_new_mail = async {
loop {
match k2v_wait_value_changed(&k2v, INCOMING_PK, INCOMING_WATCH_SK, &prev_ct)
.await
{
Ok(cv) => break cv,
match storage.row_poll(&incoming_key).await {
Ok(row_val) => break row_val.row_ref,
Err(e) => {
error!("Error in wait_new_mail: {}", e);
tokio::time::sleep(Duration::from_secs(30)).await;
@ -81,13 +77,13 @@ async fn incoming_mail_watch_process_internal(
};
tokio::select! {
cv = wait_new_mail => Some(cv.causality),
_ = tokio::time::sleep(MAIL_CHECK_INTERVAL) => prev_ct.clone(),
_ = lock_held.changed() => None,
_ = rx_inbox_id.changed() => None,
inc_k = wait_new_mail => Some(inc_k),
_ = tokio::time::sleep(MAIL_CHECK_INTERVAL) => Some(incoming_key.clone()),
_ = lock_held.changed() => None,
_ = rx_inbox_id.changed() => None,
}
} else {
info!("incoming lock not held");
debug!("incoming lock not held");
tokio::select! {
_ = lock_held.changed() => None,
_ = rx_inbox_id.changed() => None,
@ -97,11 +93,11 @@ async fn incoming_mail_watch_process_internal(
let user = match Weak::upgrade(&user) {
Some(user) => user,
None => {
info!("User no longer available, exiting incoming loop.");
debug!("User no longer available, exiting incoming loop.");
break;
}
};
info!("User still available");
debug!("User still available");
// If INBOX no longer is same mailbox, open new mailbox
let inbox_id = *rx_inbox_id.borrow();
@ -123,10 +119,10 @@ async fn incoming_mail_watch_process_internal(
// If we were able to open INBOX, and we have mail,
// fetch new mail
if let (Some(inbox), Some(new_ct)) = (&inbox, new_mail) {
match handle_incoming_mail(&user, &s3, inbox, &lock_held).await {
if let (Some(inbox), Some(updated_incoming_key)) = (&inbox, maybe_updated_incoming_key) {
match handle_incoming_mail(&user, &storage, inbox, &lock_held).await {
Ok(()) => {
prev_ct = Some(new_ct);
incoming_key = updated_incoming_key;
}
Err(e) => {
error!("Could not fetch incoming mail: {}", e);
@ -141,27 +137,20 @@ async fn incoming_mail_watch_process_internal(
async fn handle_incoming_mail(
user: &Arc<User>,
s3: &S3Client,
storage: &storage::Store,
inbox: &Arc<Mailbox>,
lock_held: &watch::Receiver<bool>,
) -> Result<()> {
let lor = ListObjectsV2Request {
bucket: user.creds.storage.bucket.clone(),
max_keys: Some(1000),
prefix: Some("incoming/".into()),
..Default::default()
};
let mails_res = s3.list_objects_v2(lor).await?;
let mails_res = storage.blob_list("incoming/").await?;
for object in mails_res.contents.unwrap_or_default() {
for object in mails_res {
if !*lock_held.borrow() {
break;
}
if let Some(key) = object.key {
if let Some(mail_id) = key.strip_prefix("incoming/") {
if let Ok(mail_id) = mail_id.parse::<UniqueIdent>() {
move_incoming_message(user, s3, inbox, mail_id).await?;
}
let key = object.0;
if let Some(mail_id) = key.strip_prefix("incoming/") {
if let Ok(mail_id) = mail_id.parse::<UniqueIdent>() {
move_incoming_message(user, storage, inbox, mail_id).await?;
}
}
}
@ -171,7 +160,7 @@ async fn handle_incoming_mail(
async fn move_incoming_message(
user: &Arc<User>,
s3: &S3Client,
storage: &storage::Store,
inbox: &Arc<Mailbox>,
id: UniqueIdent,
) -> Result<()> {
@ -180,22 +169,15 @@ async fn move_incoming_message(
let object_key = format!("incoming/{}", id);
// 1. Fetch message from S3
let gor = GetObjectRequest {
bucket: user.creds.storage.bucket.clone(),
key: object_key.clone(),
..Default::default()
};
let get_result = s3.get_object(gor).await?;
let object = storage.blob_fetch(&storage::BlobRef(object_key)).await?;
// 1.a decrypt message key from headers
info!("Object metadata: {:?}", get_result.metadata);
let key_encrypted_b64 = get_result
.metadata
.as_ref()
.ok_or(anyhow!("Missing key in metadata"))?
//info!("Object metadata: {:?}", get_result.metadata);
let key_encrypted_b64 = object
.meta
.get(MESSAGE_KEY)
.ok_or(anyhow!("Missing key in metadata"))?;
let key_encrypted = base64::decode(key_encrypted_b64)?;
let key_encrypted = base64::engine::general_purpose::STANDARD.decode(key_encrypted_b64)?;
let message_key = sodiumoxide::crypto::sealedbox::open(
&key_encrypted,
&user.creds.keys.public,
@ -206,38 +188,28 @@ async fn move_incoming_message(
cryptoblob::Key::from_slice(&message_key).ok_or(anyhow!("Invalid message key"))?;
// 1.b retrieve message body
let obj_body = get_result.body.ok_or(anyhow!("Missing object body"))?;
let mut mail_buf = Vec::with_capacity(get_result.content_length.unwrap_or(128) as usize);
obj_body
.into_async_read()
.read_to_end(&mut mail_buf)
.await?;
let plain_mail = cryptoblob::open(&mail_buf, &message_key)
let obj_body = object.value;
let plain_mail = cryptoblob::open(&obj_body, &message_key)
.map_err(|_| anyhow!("Cannot decrypt email content"))?;
// 2 parse mail and add to inbox
let msg = IMF::try_from(&plain_mail[..]).map_err(|_| anyhow!("Invalid email body"))?;
inbox
.append_from_s3(msg, id, &object_key, message_key)
.append_from_s3(msg, id, object.blob_ref.clone(), message_key)
.await?;
// 3 delete from incoming
let dor = DeleteObjectRequest {
bucket: user.creds.storage.bucket.clone(),
key: object_key.clone(),
..Default::default()
};
s3.delete_object(dor).await?;
storage.blob_rm(&object.blob_ref).await?;
Ok(())
}
// ---- UTIL: K2V locking loop, use this to try to grab a lock using a K2V entry as a signal ----
fn k2v_lock_loop(k2v: K2vClient, pk: &'static str, sk: &'static str) -> watch::Receiver<bool> {
fn k2v_lock_loop(storage: storage::Store, row_ref: storage::RowRef) -> watch::Receiver<bool> {
let (held_tx, held_rx) = watch::channel(false);
tokio::spawn(k2v_lock_loop_internal(k2v, pk, sk, held_tx));
tokio::spawn(k2v_lock_loop_internal(storage, row_ref, held_tx));
held_rx
}
@ -246,13 +218,12 @@ fn k2v_lock_loop(k2v: K2vClient, pk: &'static str, sk: &'static str) -> watch::R
enum LockState {
Unknown,
Empty,
Held(UniqueIdent, u64, CausalityToken),
Held(UniqueIdent, u64, storage::RowRef),
}
async fn k2v_lock_loop_internal(
k2v: K2vClient,
pk: &'static str,
sk: &'static str,
storage: storage::Store,
row_ref: storage::RowRef,
held_tx: watch::Sender<bool>,
) {
let (state_tx, mut state_rx) = watch::channel::<LockState>(LockState::Unknown);
@ -262,10 +233,10 @@ async fn k2v_lock_loop_internal(
// Loop 1: watch state of lock in K2V, save that in corresponding watch channel
let watch_lock_loop: BoxFuture<Result<()>> = async {
let mut ct = None;
let mut ct = row_ref.clone();
loop {
info!("k2v watch lock loop iter: ct = {:?}", ct);
match k2v_wait_value_changed(&k2v, pk, sk, &ct).await {
debug!("k2v watch lock loop iter: ct = {:?}", ct);
match storage.row_poll(&ct).await {
Err(e) => {
error!(
"Error in k2v wait value changed: {} ; assuming we no longer hold lock.",
@ -277,7 +248,7 @@ async fn k2v_lock_loop_internal(
Ok(cv) => {
let mut lock_state = None;
for v in cv.value.iter() {
if let K2vValue::Value(vbytes) = v {
if let storage::Alternative::Value(vbytes) = v {
if vbytes.len() == 32 {
let ts = u64::from_be_bytes(vbytes[..8].try_into().unwrap());
let pid = UniqueIdent(vbytes[8..].try_into().unwrap());
@ -290,16 +261,18 @@ async fn k2v_lock_loop_internal(
}
}
}
info!(
let new_ct = cv.row_ref;
debug!(
"k2v watch lock loop: changed, old ct = {:?}, new ct = {:?}, v = {:?}",
ct, cv.causality, lock_state
ct, new_ct, lock_state
);
state_tx.send(
lock_state
.map(|(pid, ts)| LockState::Held(pid, ts, cv.causality.clone()))
.map(|(pid, ts)| LockState::Held(pid, ts, new_ct.clone()))
.unwrap_or(LockState::Empty),
)?;
ct = Some(cv.causality);
ct = new_ct;
}
}
}
@ -385,7 +358,14 @@ async fn k2v_lock_loop_internal(
now_msec() + LOCK_DURATION.as_millis() as u64,
));
lock[8..].copy_from_slice(&our_pid.0);
if let Err(e) = k2v.insert_item(pk, sk, lock, ct).await {
let row = match ct {
Some(existing) => existing,
None => row_ref.clone(),
};
if let Err(e) = storage
.row_insert(vec![storage::RowVal::new(row, lock)])
.await
{
error!("Could not take lock: {}", e);
tokio::time::sleep(Duration::from_secs(30)).await;
}
@ -398,10 +378,10 @@ async fn k2v_lock_loop_internal(
let _ = futures::try_join!(watch_lock_loop, lock_notify_loop, take_lock_loop);
info!("lock loop exited, releasing");
debug!("lock loop exited, releasing");
if !held_tx.is_closed() {
warn!("wierd...");
warn!("weird...");
let _ = held_tx.send(false);
}
@ -411,7 +391,10 @@ async fn k2v_lock_loop_internal(
_ => None,
};
if let Some(ct) = release {
let _ = k2v.delete_item(pk, sk, ct.clone()).await;
match storage.row_rm(&storage::Selector::Single(&ct)).await {
Err(e) => warn!("Unable to release lock {:?}: {}", ct, e),
Ok(_) => (),
};
}
}
@ -433,43 +416,30 @@ impl EncryptedMessage {
}
pub async fn deliver_to(self: Arc<Self>, creds: PublicCredentials) -> Result<()> {
let s3_client = creds.storage.s3_client()?;
let k2v_client = creds.storage.k2v_client()?;
let storage = creds.storage.build().await?;
// Get causality token of previous watch key
let watch_ct = match k2v_client.read_item(INCOMING_PK, INCOMING_WATCH_SK).await {
Err(_) => None,
Ok(cv) => Some(cv.causality),
let query = storage::RowRef::new(INCOMING_PK, INCOMING_WATCH_SK);
let watch_ct = match storage.row_fetch(&storage::Selector::Single(&query)).await {
Err(_) => query,
Ok(cv) => cv.into_iter().next().map(|v| v.row_ref).unwrap_or(query),
};
// Write mail to encrypted storage
let encrypted_key =
sodiumoxide::crypto::sealedbox::seal(self.key.as_ref(), &creds.public_key);
let key_header = base64::encode(&encrypted_key);
let key_header = base64::engine::general_purpose::STANDARD.encode(&encrypted_key);
let por = PutObjectRequest {
bucket: creds.storage.bucket.clone(),
key: format!("incoming/{}", gen_ident()),
metadata: Some(
[(MESSAGE_KEY.to_string(), key_header)]
.into_iter()
.collect::<HashMap<_, _>>(),
),
body: Some(self.encrypted_body.clone().into()),
..Default::default()
};
s3_client.put_object(por).await?;
let blob_val = storage::BlobVal::new(
storage::BlobRef(format!("incoming/{}", gen_ident())),
self.encrypted_body.clone().into(),
)
.with_meta(MESSAGE_KEY.to_string(), key_header);
storage.blob_insert(blob_val).await?;
// Update watch key to signal new mail
k2v_client
.insert_item(
INCOMING_PK,
INCOMING_WATCH_SK,
gen_ident().0.to_vec(),
watch_ct,
)
.await?;
let watch_val = storage::RowVal::new(watch_ct.clone(), gen_ident().0.to_vec());
storage.row_insert(vec![watch_val]).await?;
Ok(())
}
}

View file

@ -1,11 +1,5 @@
use anyhow::{anyhow, bail, Result};
use k2v_client::K2vClient;
use k2v_client::{BatchReadOp, Filter, K2vValue};
use rusoto_s3::{
CopyObjectRequest, DeleteObjectRequest, GetObjectRequest, PutObjectRequest, S3Client, S3,
};
use serde::{Deserialize, Serialize};
use tokio::io::AsyncReadExt;
use tokio::sync::RwLock;
use crate::bayou::Bayou;
@ -14,7 +8,8 @@ use crate::login::Credentials;
use crate::mail::uidindex::*;
use crate::mail::unique_ident::*;
use crate::mail::IMF;
use crate::time::now_msec;
use crate::storage::{self, BlobRef, BlobVal, RowRef, RowVal, Selector, Store};
use crate::timestamp::now_msec;
pub struct Mailbox {
pub(super) id: UniqueIdent,
@ -30,7 +25,7 @@ impl Mailbox {
let index_path = format!("index/{}", id);
let mail_path = format!("mail/{}", id);
let mut uid_index = Bayou::<UidIndex>::new(creds, index_path)?;
let mut uid_index = Bayou::<UidIndex>::new(creds, index_path).await?;
uid_index.sync().await?;
let uidvalidity = uid_index.state().uidvalidity;
@ -44,14 +39,16 @@ impl Mailbox {
.await?;
}
// @FIXME reporting through opentelemetry or some logs
// info on the "shape" of the mailbox would be welcomed
/*
dump(&uid_index);
*/
let mbox = RwLock::new(MailboxInternal {
id,
bucket: creds.bucket().to_string(),
encryption_key: creds.keys.master.clone(),
k2v: creds.k2v_client()?,
s3: creds.s3_client()?,
storage: creds.storage.build().await?,
uid_index,
mail_path,
});
@ -70,6 +67,11 @@ impl Mailbox {
self.mbox.write().await.opportunistic_sync().await
}
/// Block until a sync has been done (due to changes in the event log)
pub async fn notify(&self) -> std::sync::Weak<tokio::sync::Notify> {
self.mbox.read().await.notifier()
}
// ---- Functions for reading the mailbox ----
/// Get a clone of the current UID Index of this mailbox
@ -89,6 +91,10 @@ impl Mailbox {
self.mbox.read().await.fetch_full(id, message_key).await
}
pub async fn frozen(self: &std::sync::Arc<Self>) -> super::snapshot::FrozenMailbox {
super::snapshot::FrozenMailbox::new(self.clone()).await
}
// ---- Functions for changing the mailbox ----
/// Add flags to message
@ -112,7 +118,7 @@ impl Mailbox {
msg: IMF<'a>,
ident: Option<UniqueIdent>,
flags: &[Flag],
) -> Result<(ImapUidvalidity, ImapUid)> {
) -> Result<(ImapUidvalidity, ImapUid, ModSeq)> {
self.mbox.write().await.append(msg, ident, flags).await
}
@ -121,13 +127,13 @@ impl Mailbox {
&self,
msg: IMF<'a>,
ident: UniqueIdent,
s3_key: &str,
blob_ref: storage::BlobRef,
message_key: Key,
) -> Result<()> {
self.mbox
.write()
.await
.append_from_s3(msg, ident, s3_key, message_key)
.append_from_s3(msg, ident, blob_ref, message_key)
.await
}
@ -156,7 +162,6 @@ impl Mailbox {
/// Move an email from an other Mailbox to this mailbox
/// (use this when possible, as it allows for a certain number of storage optimizations)
#[allow(dead_code)]
pub async fn move_from(&self, from: &Mailbox, uuid: UniqueIdent) -> Result<()> {
if self.id == from.id {
bail!("Cannot copy move same mailbox");
@ -182,13 +187,9 @@ struct MailboxInternal {
// 2023-05-15 will probably be used later.
#[allow(dead_code)]
id: UniqueIdent,
bucket: String,
mail_path: String,
encryption_key: Key,
k2v: K2vClient,
s3: S3Client,
storage: Store,
uid_index: Bayou<UidIndex>,
}
@ -203,39 +204,29 @@ impl MailboxInternal {
Ok(())
}
fn notifier(&self) -> std::sync::Weak<tokio::sync::Notify> {
self.uid_index.notifier()
}
// ---- Functions for reading the mailbox ----
async fn fetch_meta(&self, ids: &[UniqueIdent]) -> Result<Vec<MailMeta>> {
let ids = ids.iter().map(|x| x.to_string()).collect::<Vec<_>>();
let ops = ids
.iter()
.map(|id| BatchReadOp {
partition_key: &self.mail_path,
filter: Filter {
start: Some(id),
end: None,
prefix: None,
limit: None,
reverse: false,
},
single_item: true,
conflicts_only: false,
tombstones: false,
})
.map(|id| RowRef::new(self.mail_path.as_str(), id.as_str()))
.collect::<Vec<_>>();
let res_vec = self.k2v.read_batch(&ops).await?;
let res_vec = self.storage.row_fetch(&Selector::List(ops)).await?;
let mut meta_vec = vec![];
for (op, res) in ops.iter().zip(res_vec.into_iter()) {
if res.items.len() != 1 {
bail!("Expected 1 item, got {}", res.items.len());
}
let (_, cv) = res.items.iter().next().unwrap();
for res in res_vec.into_iter() {
let mut meta_opt = None;
for v in cv.value.iter() {
// Resolve conflicts
for v in res.value.iter() {
match v {
K2vValue::Tombstone => (),
K2vValue::Value(v) => {
storage::Alternative::Tombstone => (),
storage::Alternative::Value(v) => {
let meta = open_deserialize::<MailMeta>(v, &self.encryption_key)?;
match meta_opt.as_mut() {
None => {
@ -251,7 +242,7 @@ impl MailboxInternal {
if let Some(meta) = meta_opt {
meta_vec.push(meta);
} else {
bail!("No valid meta value in k2v for {:?}", op.filter.start);
bail!("No valid meta value in k2v for {:?}", res.row_ref);
}
}
@ -259,19 +250,12 @@ impl MailboxInternal {
}
async fn fetch_full(&self, id: UniqueIdent, message_key: &Key) -> Result<Vec<u8>> {
let gor = GetObjectRequest {
bucket: self.bucket.clone(),
key: format!("{}/{}", self.mail_path, id),
..Default::default()
};
let obj_res = self.s3.get_object(gor).await?;
let obj_body = obj_res.body.ok_or(anyhow!("Missing object body"))?;
let mut buf = Vec::with_capacity(obj_res.content_length.unwrap_or(128) as usize);
obj_body.into_async_read().read_to_end(&mut buf).await?;
cryptoblob::open(&buf, message_key)
let obj_res = self
.storage
.blob_fetch(&BlobRef(format!("{}/{}", self.mail_path, id)))
.await?;
let body = obj_res.value;
cryptoblob::open(&body, message_key)
}
// ---- Functions for changing the mailbox ----
@ -296,7 +280,7 @@ impl MailboxInternal {
mail: IMF<'_>,
ident: Option<UniqueIdent>,
flags: &[Flag],
) -> Result<(ImapUidvalidity, ImapUid)> {
) -> Result<(ImapUidvalidity, ImapUid, ModSeq)> {
let ident = ident.unwrap_or_else(gen_ident);
let message_key = gen_key();
@ -304,13 +288,12 @@ impl MailboxInternal {
async {
// Encrypt and save mail body
let message_blob = cryptoblob::seal(mail.raw, &message_key)?;
let por = PutObjectRequest {
bucket: self.bucket.clone(),
key: format!("{}/{}", self.mail_path, ident),
body: Some(message_blob.into()),
..Default::default()
};
self.s3.put_object(por).await?;
self.storage
.blob_insert(BlobVal::new(
BlobRef(format!("{}/{}", self.mail_path, ident)),
message_blob,
))
.await?;
Ok::<_, anyhow::Error>(())
},
async {
@ -322,8 +305,11 @@ impl MailboxInternal {
rfc822_size: mail.raw.len(),
};
let meta_blob = seal_serialize(&meta, &self.encryption_key)?;
self.k2v
.insert_item(&self.mail_path, &ident.to_string(), meta_blob, None)
self.storage
.row_insert(vec![RowVal::new(
RowRef::new(&self.mail_path, &ident.to_string()),
meta_blob,
)])
.await?;
Ok::<_, anyhow::Error>(())
},
@ -335,47 +321,44 @@ impl MailboxInternal {
let add_mail_op = uid_state.op_mail_add(ident, flags.to_vec());
let uidvalidity = uid_state.uidvalidity;
let uid = match add_mail_op {
UidIndexOp::MailAdd(_, uid, _) => uid,
let (uid, modseq) = match add_mail_op {
UidIndexOp::MailAdd(_, uid, modseq, _) => (uid, modseq),
_ => unreachable!(),
};
self.uid_index.push(add_mail_op).await?;
Ok((uidvalidity, uid))
Ok((uidvalidity, uid, modseq))
}
async fn append_from_s3<'a>(
&mut self,
mail: IMF<'a>,
ident: UniqueIdent,
s3_key: &str,
blob_src: storage::BlobRef,
message_key: Key,
) -> Result<()> {
futures::try_join!(
async {
// Copy mail body from previous location
let cor = CopyObjectRequest {
bucket: self.bucket.clone(),
key: format!("{}/{}", self.mail_path, ident),
copy_source: format!("{}/{}", self.bucket, s3_key),
metadata_directive: Some("REPLACE".into()),
..Default::default()
};
self.s3.copy_object(cor).await?;
let blob_dst = BlobRef(format!("{}/{}", self.mail_path, ident));
self.storage.blob_copy(&blob_src, &blob_dst).await?;
Ok::<_, anyhow::Error>(())
},
async {
// Save mail meta
let meta = MailMeta {
internaldate: now_msec(),
headers: mail.parsed.raw_headers.to_vec(),
headers: mail.parsed.raw_headers.to_vec(),
message_key: message_key.clone(),
rfc822_size: mail.raw.len(),
};
let meta_blob = seal_serialize(&meta, &self.encryption_key)?;
self.k2v
.insert_item(&self.mail_path, &ident.to_string(), meta_blob, None)
self.storage
.row_insert(vec![RowVal::new(
RowRef::new(&self.mail_path, &ident.to_string()),
meta_blob,
)])
.await?;
Ok::<_, anyhow::Error>(())
},
@ -400,21 +383,26 @@ impl MailboxInternal {
futures::try_join!(
async {
// Delete mail body from S3
let dor = DeleteObjectRequest{
bucket: self.bucket.clone(),
key: format!("{}/{}", self.mail_path, ident),
..Default::default()
};
self.s3.delete_object(dor).await?;
self.storage
.blob_rm(&BlobRef(format!("{}/{}", self.mail_path, ident)))
.await?;
Ok::<_, anyhow::Error>(())
},
async {
// Delete mail meta from K2V
let sk = ident.to_string();
let v = self.k2v.read_item(&self.mail_path, &sk).await?;
self.k2v
.delete_item(&self.mail_path, &sk, v.causality)
let res = self
.storage
.row_fetch(&storage::Selector::Single(&RowRef::new(
&self.mail_path,
&sk,
)))
.await?;
if let Some(row_val) = res.into_iter().next() {
self.storage
.row_rm(&storage::Selector::Single(&row_val.row_ref))
.await?;
}
Ok::<_, anyhow::Error>(())
}
)?;
@ -431,8 +419,6 @@ impl MailboxInternal {
Ok(new_id)
}
#[allow(dead_code)]
// 2023-05-15 will probably be used later
async fn move_from(&mut self, from: &mut MailboxInternal, id: UniqueIdent) -> Result<()> {
self.copy_internal(from, id, id).await?;
from.delete(id).await?;
@ -445,7 +431,7 @@ impl MailboxInternal {
source_id: UniqueIdent,
new_id: UniqueIdent,
) -> Result<()> {
if self.bucket != from.bucket || self.encryption_key != from.encryption_key {
if self.encryption_key != from.encryption_key {
bail!("Message to be copied/moved does not belong to same account.");
}
@ -455,28 +441,25 @@ impl MailboxInternal {
.table
.get(&source_id)
.ok_or(anyhow!("Source mail not found"))?
.1
.2
.clone();
futures::try_join!(
async {
// Copy mail body from S3
let cor = CopyObjectRequest{
bucket: self.bucket.clone(),
key: format!("{}/{}", self.mail_path, new_id),
copy_source: format!("{}/{}/{}", from.bucket, from.mail_path, source_id),
..Default::default()
};
self.s3.copy_object(cor).await?;
let dst = BlobRef(format!("{}/{}", self.mail_path, new_id));
let src = BlobRef(format!("{}/{}", from.mail_path, source_id));
self.storage.blob_copy(&src, &dst).await?;
Ok::<_, anyhow::Error>(())
},
async {
// Copy mail meta in K2V
let meta = &from.fetch_meta(&[source_id]).await?[0];
let meta_blob = seal_serialize(meta, &self.encryption_key)?;
self.k2v
.insert_item(&self.mail_path, &new_id.to_string(), meta_blob, None)
self.storage
.row_insert(vec![RowVal::new(
RowRef::new(&self.mail_path, &new_id.to_string()),
meta_blob,
)])
.await?;
Ok::<_, anyhow::Error>(())
},
@ -491,6 +474,9 @@ impl MailboxInternal {
}
}
// Can be useful to debug so we want this code
// to be available to developers
#[allow(dead_code)]
fn dump(uid_index: &Bayou<UidIndex>) {
let s = uid_index.state();
println!("---- MAILBOX STATE ----");
@ -502,7 +488,7 @@ fn dump(uid_index: &Bayou<UidIndex>) {
"{} {} {}",
uid,
hex::encode(ident.0),
s.table.get(ident).cloned().unwrap().1.join(", ")
s.table.get(ident).cloned().unwrap().2.join(", ")
);
}
println!();
@ -512,7 +498,7 @@ fn dump(uid_index: &Bayou<UidIndex>) {
/// The metadata of a message that is stored in K2V
/// at pk = mail/<mailbox uuid>, sk = <message uuid>
#[derive(Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MailMeta {
/// INTERNALDATE field (milliseconds since epoch)
pub internaldate: u64,

View file

@ -1,8 +1,9 @@
use std::convert::TryFrom;
use std::io::Write;
pub mod incoming;
pub mod mailbox;
pub mod query;
pub mod snapshot;
pub mod uidindex;
pub mod unique_ident;
pub mod user;
@ -20,9 +21,6 @@ impl<'a> TryFrom<&'a [u8]> for IMF<'a> {
type Error = ();
fn try_from(body: &'a [u8]) -> Result<IMF<'a>, ()> {
eprintln!("---- BEGIN PARSED MESSAGE ----");
let _ = std::io::stderr().write_all(body);
eprintln!("---- END PARSED MESSAGE ----");
let parsed = eml_codec::parse_message(body).or(Err(()))?.1;
Ok(Self { raw: body, parsed })
}

137
src/mail/query.rs Normal file
View file

@ -0,0 +1,137 @@
use super::mailbox::MailMeta;
use super::snapshot::FrozenMailbox;
use super::unique_ident::UniqueIdent;
use anyhow::Result;
use futures::future::FutureExt;
use futures::stream::{BoxStream, Stream, StreamExt};
/// Query is in charge of fetching efficiently
/// requested data for a list of emails
pub struct Query<'a, 'b> {
pub frozen: &'a FrozenMailbox,
pub emails: &'b [UniqueIdent],
pub scope: QueryScope,
}
#[derive(Debug)]
pub enum QueryScope {
Index,
Partial,
Full,
}
impl QueryScope {
pub fn union(&self, other: &QueryScope) -> QueryScope {
match (self, other) {
(QueryScope::Full, _) | (_, QueryScope::Full) => QueryScope::Full,
(QueryScope::Partial, _) | (_, QueryScope::Partial) => QueryScope::Partial,
(QueryScope::Index, QueryScope::Index) => QueryScope::Index,
}
}
}
//type QueryResultStream = Box<dyn Stream<Item = Result<QueryResult>>>;
impl<'a, 'b> Query<'a, 'b> {
pub fn fetch(&self) -> BoxStream<Result<QueryResult>> {
match self.scope {
QueryScope::Index => Box::pin(
futures::stream::iter(self.emails)
.map(|&uuid| Ok(QueryResult::IndexResult { uuid })),
),
QueryScope::Partial => Box::pin(self.partial()),
QueryScope::Full => Box::pin(self.full()),
}
}
// --- functions below are private *for reasons*
fn partial<'d>(&'d self) -> impl Stream<Item = Result<QueryResult>> + 'd + Send {
async move {
let maybe_meta_list: Result<Vec<MailMeta>> =
self.frozen.mailbox.fetch_meta(self.emails).await;
let list_res = maybe_meta_list
.map(|meta_list| {
meta_list
.into_iter()
.zip(self.emails)
.map(|(metadata, &uuid)| Ok(QueryResult::PartialResult { uuid, metadata }))
.collect()
})
.unwrap_or_else(|e| vec![Err(e)]);
futures::stream::iter(list_res)
}
.flatten_stream()
}
fn full<'d>(&'d self) -> impl Stream<Item = Result<QueryResult>> + 'd + Send {
self.partial().then(move |maybe_meta| async move {
let meta = maybe_meta?;
let content = self
.frozen
.mailbox
.fetch_full(
*meta.uuid(),
&meta
.metadata()
.expect("meta to be PartialResult")
.message_key,
)
.await?;
Ok(meta.into_full(content).expect("meta to be PartialResult"))
})
}
}
#[derive(Debug, Clone)]
pub enum QueryResult {
IndexResult {
uuid: UniqueIdent,
},
PartialResult {
uuid: UniqueIdent,
metadata: MailMeta,
},
FullResult {
uuid: UniqueIdent,
metadata: MailMeta,
content: Vec<u8>,
},
}
impl QueryResult {
pub fn uuid(&self) -> &UniqueIdent {
match self {
Self::IndexResult { uuid, .. } => uuid,
Self::PartialResult { uuid, .. } => uuid,
Self::FullResult { uuid, .. } => uuid,
}
}
pub fn metadata(&self) -> Option<&MailMeta> {
match self {
Self::IndexResult { .. } => None,
Self::PartialResult { metadata, .. } => Some(metadata),
Self::FullResult { metadata, .. } => Some(metadata),
}
}
#[allow(dead_code)]
pub fn content(&self) -> Option<&[u8]> {
match self {
Self::FullResult { content, .. } => Some(content),
_ => None,
}
}
fn into_full(self, content: Vec<u8>) -> Option<Self> {
match self {
Self::PartialResult { uuid, metadata } => Some(Self::FullResult {
uuid,
metadata,
content,
}),
_ => None,
}
}
}

60
src/mail/snapshot.rs Normal file
View file

@ -0,0 +1,60 @@
use std::sync::Arc;
use anyhow::Result;
use super::mailbox::Mailbox;
use super::query::{Query, QueryScope};
use super::uidindex::UidIndex;
use super::unique_ident::UniqueIdent;
/// A Frozen Mailbox has a snapshot of the current mailbox
/// state that is desynchronized with the real mailbox state.
/// It's up to the user to choose when their snapshot must be updated
/// to give useful information to their clients
pub struct FrozenMailbox {
pub mailbox: Arc<Mailbox>,
pub snapshot: UidIndex,
}
impl FrozenMailbox {
/// Create a snapshot from a mailbox, the mailbox + the snapshot
/// becomes the "Frozen Mailbox".
pub async fn new(mailbox: Arc<Mailbox>) -> Self {
let state = mailbox.current_uid_index().await;
Self {
mailbox,
snapshot: state,
}
}
/// Force the synchronization of the inner mailbox
/// but do not update the local snapshot
pub async fn sync(&self) -> Result<()> {
self.mailbox.opportunistic_sync().await
}
/// Peek snapshot without updating the frozen mailbox
/// Can be useful if you want to plan some writes
/// while sending a diff to the client later
pub async fn peek(&self) -> UidIndex {
self.mailbox.current_uid_index().await
}
/// Update the FrozenMailbox local snapshot.
/// Returns the old snapshot, so you can build a diff
pub async fn update(&mut self) -> UidIndex {
let old_snapshot = self.snapshot.clone();
self.snapshot = self.mailbox.current_uid_index().await;
old_snapshot
}
pub fn query<'a, 'b>(&'a self, uuids: &'b [UniqueIdent], scope: QueryScope) -> Query<'a, 'b> {
Query {
frozen: self,
emails: uuids,
scope,
}
}
}

View file

@ -1,4 +1,4 @@
use std::num::NonZeroU32;
use std::num::{NonZeroU32, NonZeroU64};
use im::{HashMap, OrdMap, OrdSet};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
@ -6,9 +6,11 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::bayou::*;
use crate::mail::unique_ident::UniqueIdent;
pub type ModSeq = NonZeroU64;
pub type ImapUid = NonZeroU32;
pub type ImapUidvalidity = NonZeroU32;
pub type Flag = String;
pub type IndexEntry = (ImapUid, ModSeq, Vec<Flag>);
/// A UidIndex handles the mutable part of a mailbox
/// It is built by running the event log on it
@ -18,32 +20,37 @@ pub type Flag = String;
#[derive(Clone)]
pub struct UidIndex {
// Source of trust
pub table: OrdMap<UniqueIdent, (ImapUid, Vec<Flag>)>,
pub table: OrdMap<UniqueIdent, IndexEntry>,
// Indexes optimized for queries
pub idx_by_uid: OrdMap<ImapUid, UniqueIdent>,
pub idx_by_modseq: OrdMap<ModSeq, UniqueIdent>,
pub idx_by_flag: FlagIndex,
// Counters
// "Public" Counters
pub uidvalidity: ImapUidvalidity,
pub uidnext: ImapUid,
pub highestmodseq: ModSeq,
// "Internal" Counters
pub internalseq: ImapUid,
pub internalmodseq: ModSeq,
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub enum UidIndexOp {
MailAdd(UniqueIdent, ImapUid, Vec<Flag>),
MailAdd(UniqueIdent, ImapUid, ModSeq, Vec<Flag>),
MailDel(UniqueIdent),
FlagAdd(UniqueIdent, Vec<Flag>),
FlagDel(UniqueIdent, Vec<Flag>),
FlagSet(UniqueIdent, Vec<Flag>),
FlagAdd(UniqueIdent, ModSeq, Vec<Flag>),
FlagDel(UniqueIdent, ModSeq, Vec<Flag>),
FlagSet(UniqueIdent, ModSeq, Vec<Flag>),
BumpUidvalidity(u32),
}
impl UidIndex {
#[must_use]
pub fn op_mail_add(&self, ident: UniqueIdent, flags: Vec<Flag>) -> UidIndexOp {
UidIndexOp::MailAdd(ident, self.internalseq, flags)
UidIndexOp::MailAdd(ident, self.internalseq, self.internalmodseq, flags)
}
#[must_use]
@ -53,17 +60,17 @@ impl UidIndex {
#[must_use]
pub fn op_flag_add(&self, ident: UniqueIdent, flags: Vec<Flag>) -> UidIndexOp {
UidIndexOp::FlagAdd(ident, flags)
UidIndexOp::FlagAdd(ident, self.internalmodseq, flags)
}
#[must_use]
pub fn op_flag_del(&self, ident: UniqueIdent, flags: Vec<Flag>) -> UidIndexOp {
UidIndexOp::FlagDel(ident, flags)
UidIndexOp::FlagDel(ident, self.internalmodseq, flags)
}
#[must_use]
pub fn op_flag_set(&self, ident: UniqueIdent, flags: Vec<Flag>) -> UidIndexOp {
UidIndexOp::FlagSet(ident, flags)
UidIndexOp::FlagSet(ident, self.internalmodseq, flags)
}
#[must_use]
@ -73,18 +80,19 @@ impl UidIndex {
// INTERNAL functions to keep state consistent
fn reg_email(&mut self, ident: UniqueIdent, uid: ImapUid, flags: &[Flag]) {
fn reg_email(&mut self, ident: UniqueIdent, uid: ImapUid, modseq: ModSeq, flags: &[Flag]) {
// Insert the email in our table
self.table.insert(ident, (uid, flags.to_owned()));
self.table.insert(ident, (uid, modseq, flags.to_owned()));
// Update the indexes/caches
self.idx_by_uid.insert(uid, ident);
self.idx_by_flag.insert(uid, flags);
self.idx_by_modseq.insert(modseq, ident);
}
fn unreg_email(&mut self, ident: &UniqueIdent) {
// We do nothing if the mail does not exist
let (uid, flags) = match self.table.get(ident) {
let (uid, modseq, flags) = match self.table.get(ident) {
Some(v) => v,
None => return,
};
@ -92,6 +100,7 @@ impl UidIndex {
// Delete all cache entries
self.idx_by_uid.remove(uid);
self.idx_by_flag.remove(*uid, flags);
self.idx_by_modseq.remove(modseq);
// Remove from source of trust
self.table.remove(ident);
@ -102,11 +111,17 @@ impl Default for UidIndex {
fn default() -> Self {
Self {
table: OrdMap::new(),
idx_by_uid: OrdMap::new(),
idx_by_modseq: OrdMap::new(),
idx_by_flag: FlagIndex::new(),
uidvalidity: NonZeroU32::new(1).unwrap(),
uidnext: NonZeroU32::new(1).unwrap(),
highestmodseq: NonZeroU64::new(1).unwrap(),
internalseq: NonZeroU32::new(1).unwrap(),
internalmodseq: NonZeroU64::new(1).unwrap(),
}
}
}
@ -117,17 +132,23 @@ impl BayouState for UidIndex {
fn apply(&self, op: &UidIndexOp) -> Self {
let mut new = self.clone();
match op {
UidIndexOp::MailAdd(ident, uid, flags) => {
// Change UIDValidity if there is a conflict
if *uid < new.internalseq {
UidIndexOp::MailAdd(ident, uid, modseq, flags) => {
// Change UIDValidity if there is a UID conflict or a MODSEQ conflict
// @FIXME Need to prove that summing work
// The intuition: we increase the UIDValidity by the number of possible conflicts
if *uid < new.internalseq || *modseq < new.internalmodseq {
let bump_uid = new.internalseq.get() - uid.get();
let bump_modseq = (new.internalmodseq.get() - modseq.get()) as u32;
new.uidvalidity =
NonZeroU32::new(new.uidvalidity.get() + new.internalseq.get() - uid.get())
.unwrap();
NonZeroU32::new(new.uidvalidity.get() + bump_uid + bump_modseq).unwrap();
}
// Assign the real uid of the email
let new_uid = new.internalseq;
// Assign the real modseq of the email and its new flags
let new_modseq = new.internalmodseq;
// Delete the previous entry if any.
// Our proof has no assumption on `ident` uniqueness,
// so we must handle this case even it is very unlikely
@ -136,10 +157,14 @@ impl BayouState for UidIndex {
new.unreg_email(ident);
// We record our email and update ou caches
new.reg_email(*ident, new_uid, flags);
new.reg_email(*ident, new_uid, new_modseq, flags);
// Update counters
new.highestmodseq = new.internalmodseq;
new.internalseq = NonZeroU32::new(new.internalseq.get() + 1).unwrap();
new.internalmodseq = NonZeroU64::new(new.internalmodseq.get() + 1).unwrap();
new.uidnext = new.internalseq;
}
UidIndexOp::MailDel(ident) => {
@ -149,8 +174,16 @@ impl BayouState for UidIndex {
// We update the counter
new.internalseq = NonZeroU32::new(new.internalseq.get() + 1).unwrap();
}
UidIndexOp::FlagAdd(ident, new_flags) => {
if let Some((uid, existing_flags)) = new.table.get_mut(ident) {
UidIndexOp::FlagAdd(ident, candidate_modseq, new_flags) => {
if let Some((uid, email_modseq, existing_flags)) = new.table.get_mut(ident) {
// Bump UIDValidity if required
if *candidate_modseq < new.internalmodseq {
let bump_modseq =
(new.internalmodseq.get() - candidate_modseq.get()) as u32;
new.uidvalidity =
NonZeroU32::new(new.uidvalidity.get() + bump_modseq).unwrap();
}
// Add flags to the source of trust and the cache
let mut to_add: Vec<Flag> = new_flags
.iter()
@ -158,18 +191,48 @@ impl BayouState for UidIndex {
.cloned()
.collect();
new.idx_by_flag.insert(*uid, &to_add);
*email_modseq = new.internalmodseq;
new.idx_by_modseq.insert(new.internalmodseq, *ident);
existing_flags.append(&mut to_add);
// Update counters
new.highestmodseq = new.internalmodseq;
new.internalmodseq = NonZeroU64::new(new.internalmodseq.get() + 1).unwrap();
}
}
UidIndexOp::FlagDel(ident, rm_flags) => {
if let Some((uid, existing_flags)) = new.table.get_mut(ident) {
UidIndexOp::FlagDel(ident, candidate_modseq, rm_flags) => {
if let Some((uid, email_modseq, existing_flags)) = new.table.get_mut(ident) {
// Bump UIDValidity if required
if *candidate_modseq < new.internalmodseq {
let bump_modseq =
(new.internalmodseq.get() - candidate_modseq.get()) as u32;
new.uidvalidity =
NonZeroU32::new(new.uidvalidity.get() + bump_modseq).unwrap();
}
// Remove flags from the source of trust and the cache
existing_flags.retain(|x| !rm_flags.contains(x));
new.idx_by_flag.remove(*uid, rm_flags);
// Register that email has been modified
new.idx_by_modseq.insert(new.internalmodseq, *ident);
*email_modseq = new.internalmodseq;
// Update counters
new.highestmodseq = new.internalmodseq;
new.internalmodseq = NonZeroU64::new(new.internalmodseq.get() + 1).unwrap();
}
}
UidIndexOp::FlagSet(ident, new_flags) => {
if let Some((uid, existing_flags)) = new.table.get_mut(ident) {
UidIndexOp::FlagSet(ident, candidate_modseq, new_flags) => {
if let Some((uid, email_modseq, existing_flags)) = new.table.get_mut(ident) {
// Bump UIDValidity if required
if *candidate_modseq < new.internalmodseq {
let bump_modseq =
(new.internalmodseq.get() - candidate_modseq.get()) as u32;
new.uidvalidity =
NonZeroU32::new(new.uidvalidity.get() + bump_modseq).unwrap();
}
// Remove flags from the source of trust and the cache
let (keep_flags, rm_flags): (Vec<String>, Vec<String>) = existing_flags
.iter()
@ -184,6 +247,14 @@ impl BayouState for UidIndex {
existing_flags.append(&mut to_add);
new.idx_by_flag.remove(*uid, &rm_flags);
new.idx_by_flag.insert(*uid, &to_add);
// Register that email has been modified
new.idx_by_modseq.insert(new.internalmodseq, *ident);
*email_modseq = new.internalmodseq;
// Update counters
new.highestmodseq = new.internalmodseq;
new.internalmodseq = NonZeroU64::new(new.internalmodseq.get() + 1).unwrap();
}
}
UidIndexOp::BumpUidvalidity(count) => {
@ -237,10 +308,14 @@ impl FlagIndex {
#[derive(Serialize, Deserialize)]
struct UidIndexSerializedRepr {
mails: Vec<(ImapUid, UniqueIdent, Vec<Flag>)>,
mails: Vec<(ImapUid, ModSeq, UniqueIdent, Vec<Flag>)>,
uidvalidity: ImapUidvalidity,
uidnext: ImapUid,
highestmodseq: ModSeq,
internalseq: ImapUid,
internalmodseq: ModSeq,
}
impl<'de> Deserialize<'de> for UidIndex {
@ -252,16 +327,22 @@ impl<'de> Deserialize<'de> for UidIndex {
let mut uidindex = UidIndex {
table: OrdMap::new(),
idx_by_uid: OrdMap::new(),
idx_by_modseq: OrdMap::new(),
idx_by_flag: FlagIndex::new(),
uidvalidity: val.uidvalidity,
uidnext: val.uidnext,
highestmodseq: val.highestmodseq,
internalseq: val.internalseq,
internalmodseq: val.internalmodseq,
};
val.mails
.iter()
.for_each(|(u, i, f)| uidindex.reg_email(*i, *u, f));
.for_each(|(uid, modseq, uuid, flags)| uidindex.reg_email(*uuid, *uid, *modseq, flags));
Ok(uidindex)
}
@ -273,15 +354,17 @@ impl Serialize for UidIndex {
S: Serializer,
{
let mut mails = vec![];
for (ident, (uid, flags)) in self.table.iter() {
mails.push((*uid, *ident, flags.clone()));
for (ident, (uid, modseq, flags)) in self.table.iter() {
mails.push((*uid, *modseq, *ident, flags.clone()));
}
let val = UidIndexSerializedRepr {
mails,
uidvalidity: self.uidvalidity,
uidnext: self.uidnext,
highestmodseq: self.highestmodseq,
internalseq: self.internalseq,
internalmodseq: self.internalmodseq,
};
val.serialize(serializer)
@ -307,8 +390,9 @@ mod tests {
// Early checks
assert_eq!(state.table.len(), 1);
let (uid, flags) = state.table.get(&m).unwrap();
let (uid, modseq, flags) = state.table.get(&m).unwrap();
assert_eq!(*uid, NonZeroU32::new(1).unwrap());
assert_eq!(*modseq, NonZeroU64::new(1).unwrap());
assert_eq!(flags.len(), 2);
let ident = state.idx_by_uid.get(&NonZeroU32::new(1).unwrap()).unwrap();
assert_eq!(&m, ident);
@ -363,7 +447,12 @@ mod tests {
{
let m = UniqueIdent([0x03; 24]);
let f = vec!["\\Archive".to_string(), "\\Recent".to_string()];
let ev = UidIndexOp::MailAdd(m, NonZeroU32::new(1).unwrap(), f);
let ev = UidIndexOp::MailAdd(
m,
NonZeroU32::new(1).unwrap(),
NonZeroU64::new(1).unwrap(),
f,
);
state = state.apply(&ev);
}

View file

@ -5,7 +5,7 @@ use lazy_static::lazy_static;
use rand::prelude::*;
use serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer};
use crate::time::now_msec;
use crate::timestamp::now_msec;
/// An internal Mail Identifier is composed of two components:
/// - a process identifier, 128 bits, itself composed of:

View file

@ -2,18 +2,18 @@ use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Weak};
use anyhow::{anyhow, bail, Result};
use k2v_client::{CausalityToken, K2vClient, K2vValue};
use lazy_static::lazy_static;
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use crate::cryptoblob::{open_deserialize, seal_serialize};
use crate::login::{Credentials, StorageCredentials};
use crate::login::Credentials;
use crate::mail::incoming::incoming_mail_watch_process;
use crate::mail::mailbox::Mailbox;
use crate::mail::uidindex::ImapUidvalidity;
use crate::mail::unique_ident::{gen_ident, UniqueIdent};
use crate::time::now_msec;
use crate::storage;
use crate::timestamp::now_msec;
pub const MAILBOX_HIERARCHY_DELIMITER: char = '.';
@ -27,13 +27,26 @@ pub const MAILBOX_HIERARCHY_DELIMITER: char = '.';
/// INBOX), and we create a new empty mailbox for INBOX.
pub const INBOX: &str = "INBOX";
/// For convenience purpose, we also create some special mailbox
/// that are described in RFC6154 SPECIAL-USE
/// @FIXME maybe it should be a configuration parameter
/// @FIXME maybe we should have a per-mailbox flag mechanism, either an enum or a string, so we
/// track which mailbox is used for what.
/// @FIXME Junk could be useful but we don't have any antispam solution yet so...
/// @FIXME IMAP supports virtual mailbox. \All or \Flagged are intended to be virtual mailboxes.
/// \Trash might be one, or not one. I don't know what we should do there.
pub const DRAFTS: &str = "Drafts";
pub const ARCHIVE: &str = "Archive";
pub const SENT: &str = "Sent";
pub const TRASH: &str = "Trash";
const MAILBOX_LIST_PK: &str = "mailboxes";
const MAILBOX_LIST_SK: &str = "list";
pub struct User {
pub username: String,
pub creds: Credentials,
pub k2v: K2vClient,
pub storage: storage::Store,
pub mailboxes: std::sync::Mutex<HashMap<UniqueIdent, Weak<Mailbox>>>,
tx_inbox_id: watch::Sender<Option<(UniqueIdent, ImapUidvalidity)>>,
@ -41,7 +54,7 @@ pub struct User {
impl User {
pub async fn new(username: String, creds: Credentials) -> Result<Arc<Self>> {
let cache_key = (username.clone(), creds.storage.clone());
let cache_key = (username.clone(), creds.storage.unique());
{
let cache = USER_CACHE.lock().unwrap();
@ -71,10 +84,15 @@ impl User {
/// Opens an existing mailbox given its IMAP name.
pub async fn open_mailbox(&self, name: &str) -> Result<Option<Arc<Mailbox>>> {
let (mut list, ct) = self.load_mailbox_list().await?;
//@FIXME it could be a trace or an opentelemtry trace thing.
// Be careful to not leak sensible data
/*
eprintln!("List of mailboxes:");
for ent in list.0.iter() {
eprintln!(" - {:?}", ent);
}
*/
if let Some((uidvalidity, Some(mbid))) = list.get_mailbox(name) {
let mb = self.open_mailbox_by_id(mbid, uidvalidity).await?;
@ -119,7 +137,7 @@ impl User {
let (mut list, ct) = self.load_mailbox_list().await?;
if list.has_mailbox(name) {
// TODO: actually delete mailbox contents
//@TODO: actually delete mailbox contents
list.set_mailbox(name, None);
self.save_mailbox_list(&list, ct).await?;
Ok(())
@ -165,6 +183,7 @@ impl User {
list.rename_mailbox(name, &nnew)?;
}
}
self.save_mailbox_list(&list, ct).await?;
}
Ok(())
@ -173,14 +192,14 @@ impl User {
// ---- Internal user & mailbox management ----
async fn open(username: String, creds: Credentials) -> Result<Arc<Self>> {
let k2v = creds.k2v_client()?;
let storage = creds.storage.build().await?;
let (tx_inbox_id, rx_inbox_id) = watch::channel(None);
let user = Arc::new(Self {
username,
creds: creds.clone(),
k2v,
storage,
tx_inbox_id,
mailboxes: std::sync::Mutex::new(HashMap::new()),
});
@ -223,32 +242,53 @@ impl User {
// ---- Mailbox list management ----
async fn load_mailbox_list(&self) -> Result<(MailboxList, Option<CausalityToken>)> {
let (mut list, ct) = match self.k2v.read_item(MAILBOX_LIST_PK, MAILBOX_LIST_SK).await {
Err(k2v_client::Error::NotFound) => (MailboxList::new(), None),
async fn load_mailbox_list(&self) -> Result<(MailboxList, Option<storage::RowRef>)> {
let row_ref = storage::RowRef::new(MAILBOX_LIST_PK, MAILBOX_LIST_SK);
let (mut list, row) = match self
.storage
.row_fetch(&storage::Selector::Single(&row_ref))
.await
{
Err(storage::StorageError::NotFound) => (MailboxList::new(), None),
Err(e) => return Err(e.into()),
Ok(cv) => {
Ok(rv) => {
let mut list = MailboxList::new();
for v in cv.value {
if let K2vValue::Value(vbytes) = v {
let (row_ref, row_vals) = match rv.into_iter().next() {
Some(row_val) => (row_val.row_ref, row_val.value),
None => (row_ref, vec![]),
};
for v in row_vals {
if let storage::Alternative::Value(vbytes) = v {
let list2 =
open_deserialize::<MailboxList>(&vbytes, &self.creds.keys.master)?;
list.merge(list2);
}
}
(list, Some(cv.causality))
(list, Some(row_ref))
}
};
self.ensure_inbox_exists(&mut list, &ct).await?;
let is_default_mbx_missing = [DRAFTS, ARCHIVE, SENT, TRASH]
.iter()
.map(|mbx| list.create_mailbox(mbx))
.fold(false, |acc, r| {
acc || matches!(r, CreatedMailbox::Created(..))
});
let is_inbox_missing = self.ensure_inbox_exists(&mut list, &row).await?;
if is_default_mbx_missing && !is_inbox_missing {
// It's the only case where we created some mailboxes and not saved them
// So we save them!
self.save_mailbox_list(&list, row.clone()).await?;
}
Ok((list, ct))
Ok((list, row))
}
async fn ensure_inbox_exists(
&self,
list: &mut MailboxList,
ct: &Option<CausalityToken>,
ct: &Option<storage::RowRef>,
) -> Result<bool> {
// If INBOX doesn't exist, create a new mailbox with that name
// and save new mailbox list.
@ -277,12 +317,12 @@ impl User {
async fn save_mailbox_list(
&self,
list: &MailboxList,
ct: Option<CausalityToken>,
ct: Option<storage::RowRef>,
) -> Result<()> {
let list_blob = seal_serialize(list, &self.creds.keys.master)?;
self.k2v
.insert_item(MAILBOX_LIST_PK, MAILBOX_LIST_SK, list_blob, ct)
.await?;
let rref = ct.unwrap_or(storage::RowRef::new(MAILBOX_LIST_PK, MAILBOX_LIST_SK));
let row_val = storage::RowVal::new(rref, list_blob);
self.storage.row_insert(vec![row_val]).await?;
Ok(())
}
}
@ -334,17 +374,22 @@ impl MailboxList {
}
fn has_mailbox(&self, name: &str) -> bool {
matches!(self.0.get(name), Some(MailboxListEntry {
id_lww: (_, Some(_)),
..
}))
matches!(
self.0.get(name),
Some(MailboxListEntry {
id_lww: (_, Some(_)),
..
})
)
}
fn get_mailbox(&self, name: &str) -> Option<(ImapUidvalidity, Option<UniqueIdent>)> {
self.0.get(name).map(|MailboxListEntry {
id_lww: (_, mailbox_id),
uidvalidity,
}| (*uidvalidity, *mailbox_id))
self.0.get(name).map(
|MailboxListEntry {
id_lww: (_, mailbox_id),
uidvalidity,
}| (*uidvalidity, *mailbox_id),
)
}
/// Ensures mailbox `name` maps to id `id`.
@ -450,6 +495,6 @@ enum CreatedMailbox {
// ---- User cache ----
lazy_static! {
static ref USER_CACHE: std::sync::Mutex<HashMap<(String, StorageCredentials), Weak<User>>> =
static ref USER_CACHE: std::sync::Mutex<HashMap<(String, storage::UnicityBuffer), Weak<User>>> =
std::sync::Mutex::new(HashMap::new());
}

View file

@ -1,3 +1,6 @@
#![feature(async_fn_in_trait)]
mod auth;
mod bayou;
mod config;
mod cryptoblob;
@ -7,16 +10,17 @@ mod lmtp;
mod login;
mod mail;
mod server;
mod time;
mod storage;
mod timestamp;
use std::io::Read;
use std::path::PathBuf;
use anyhow::{bail, Result};
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use rand::prelude::*;
use nix::{sys::signal, unistd::Pid};
use config::*;
use cryptoblob::*;
use login::{static_provider::*, *};
use server::Server;
@ -25,92 +29,138 @@ use server::Server;
struct Args {
#[clap(subcommand)]
command: Command,
/// A special mode dedicated to developers, NOT INTENDED FOR PRODUCTION
#[clap(long)]
dev: bool,
#[clap(
short,
long,
env = "AEROGRAMME_CONFIG",
default_value = "aerogramme.toml"
)]
/// Path to the main Aerogramme configuration file
config_file: PathBuf,
}
#[derive(Subcommand, Debug)]
enum Command {
#[clap(subcommand)]
/// A daemon to be run by the end user, on a personal device
Companion(CompanionCommand),
#[clap(subcommand)]
/// A daemon to be run by the service provider, on a server
Provider(ProviderCommand),
#[clap(subcommand)]
/// Specific tooling, should not be part of a normal workflow, for debug & experimentation only
Tools(ToolsCommand),
//Test,
}
#[derive(Subcommand, Debug)]
enum ToolsCommand {
/// Manage crypto roots
#[clap(subcommand)]
CryptoRoot(CryptoRootCommand),
PasswordHash {
#[clap(env = "AEROGRAMME_PASSWORD")]
maybe_password: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum CryptoRootCommand {
/// Generate a new crypto-root protected with a password
New {
#[clap(env = "AEROGRAMME_PASSWORD")]
maybe_password: Option<String>,
},
/// Generate a new clear text crypto-root, store it securely!
NewClearText,
/// Change the password of a crypto key
ChangePassword {
#[clap(env = "AEROGRAMME_OLD_PASSWORD")]
maybe_old_password: Option<String>,
#[clap(env = "AEROGRAMME_NEW_PASSWORD")]
maybe_new_password: Option<String>,
#[clap(short, long, env = "AEROGRAMME_CRYPTO_ROOT")]
crypto_root: String,
},
/// From a given crypto-key, derive one containing only the public key
DeriveIncoming {
#[clap(short, long, env = "AEROGRAMME_CRYPTO_ROOT")]
crypto_root: String,
},
}
#[derive(Subcommand, Debug)]
enum CompanionCommand {
/// Runs the IMAP proxy
Daemon,
Reload {
#[clap(short, long, env = "AEROGRAMME_PID")]
pid: Option<i32>,
},
Wizard,
#[clap(subcommand)]
Account(AccountManagement),
}
#[derive(Subcommand, Debug)]
enum ProviderCommand {
/// Runs the IMAP+LMTP server daemon
Server {
#[clap(short, long, env = "CONFIG_FILE", default_value = "aerogramme.toml")]
config_file: PathBuf,
Daemon,
/// Reload the daemon
Reload {
#[clap(short, long, env = "AEROGRAMME_PID")]
pid: Option<i32>,
},
/// TEST TEST TEST
Test {
#[clap(short, long, env = "CONFIG_FILE", default_value = "aerogramme.toml")]
config_file: PathBuf,
},
/// Initializes key pairs for a user and adds a key decryption password
FirstLogin {
#[clap(flatten)]
creds: StorageCredsArgs,
#[clap(flatten)]
user_secrets: UserSecretsArgs,
},
/// Initializes key pairs for a user and dumps keys to stdout for usage with static
/// login provider
InitializeLocalKeys {
#[clap(flatten)]
creds: StorageCredsArgs,
},
/// Adds a key decryption password for a user
AddPassword {
#[clap(flatten)]
creds: StorageCredsArgs,
#[clap(flatten)]
user_secrets: UserSecretsArgs,
/// Automatically generate password
/// Manage static accounts
#[clap(subcommand)]
Account(AccountManagement),
}
#[derive(Subcommand, Debug)]
enum AccountManagement {
/// Add an account
Add {
#[clap(short, long)]
gen: bool,
login: String,
#[clap(short, long)]
setup: PathBuf,
},
/// Deletes a key decription password for a user
DeletePassword {
#[clap(flatten)]
creds: StorageCredsArgs,
#[clap(flatten)]
user_secrets: UserSecretsArgs,
/// Allow to delete all passwords
#[clap(long)]
allow_delete_all: bool,
/// Delete an account
Delete {
#[clap(short, long)]
login: String,
},
/// Dumps all encryption keys for user
ShowKeys {
#[clap(flatten)]
creds: StorageCredsArgs,
#[clap(flatten)]
user_secrets: UserSecretsArgs,
/// Change password for a given account
ChangePassword {
#[clap(env = "AEROGRAMME_OLD_PASSWORD")]
maybe_old_password: Option<String>,
#[clap(env = "AEROGRAMME_NEW_PASSWORD")]
maybe_new_password: Option<String>,
#[clap(short, long)]
login: String,
},
}
#[derive(Parser, Debug)]
struct StorageCredsArgs {
/// Name of the region to use
#[clap(short = 'r', long, env = "AWS_REGION")]
region: String,
/// Url of the endpoint to connect to for K2V
#[clap(short = 'k', long, env = "K2V_ENDPOINT")]
k2v_endpoint: String,
/// Url of the endpoint to connect to for S3
#[clap(short = 's', long, env = "S3_ENDPOINT")]
s3_endpoint: String,
/// Access key ID
#[clap(short = 'A', long, env = "AWS_ACCESS_KEY_ID")]
aws_access_key_id: String,
/// Access key ID
#[clap(short = 'S', long, env = "AWS_SECRET_ACCESS_KEY")]
aws_secret_access_key: String,
/// Bucket name
#[clap(short = 'b', long, env = "BUCKET")]
bucket: String,
#[cfg(tokio_unstable)]
fn tracer() {
console_subscriber::init();
}
#[derive(Parser, Debug)]
struct UserSecretsArgs {
/// User secret
#[clap(short = 'U', long, env = "USER_SECRET")]
user_secret: String,
/// Alternate user secrets (comma-separated list of strings)
#[clap(long, env = "ALTERNATE_USER_SECRETS", default_value = "")]
alternate_user_secrets: String,
#[cfg(not(tokio_unstable))]
fn tracer() {
tracing_subscriber::fmt::init();
}
#[tokio::main]
@ -126,194 +176,244 @@ async fn main() -> Result<()> {
std::process::abort();
}));
tracing_subscriber::fmt::init();
tracer();
let args = Args::parse();
let any_config = if args.dev {
use std::net::*;
AnyConfig::Provider(ProviderConfig {
pid: None,
imap: None,
imap_unsecure: Some(ImapUnsecureConfig {
bind_addr: SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)), 1143),
}),
lmtp: Some(LmtpConfig {
bind_addr: SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)), 1025),
hostname: "example.tld".to_string(),
}),
auth: Some(AuthConfig {
bind_addr: SocketAddr::new(
IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)),
12345,
),
}),
users: UserManagement::Demo,
})
} else {
read_config(args.config_file)?
};
match args.command {
Command::Server { config_file } => {
let config = read_config(config_file)?;
let server = Server::new(config).await?;
server.run().await?;
}
Command::Test { config_file } => {
let config = read_config(config_file)?;
let _server = Server::new(config).await?;
//server.test().await?;
}
Command::FirstLogin {
creds,
user_secrets,
} => {
let creds = make_storage_creds(creds);
let user_secrets = make_user_secrets(user_secrets);
println!("Please enter your password for key decryption.");
println!("If you are using LDAP login, this must be your LDAP password.");
println!("If you are using the static login provider, enter any password, and this will also become your password for local IMAP access.");
let password = rpassword::prompt_password("Enter password: ")?;
let password_confirm = rpassword::prompt_password("Confirm password: ")?;
if password != password_confirm {
bail!("Passwords don't match.");
match (&args.command, any_config) {
(Command::Companion(subcommand), AnyConfig::Companion(config)) => match subcommand {
CompanionCommand::Daemon => {
let server = Server::from_companion_config(config).await?;
server.run().await?;
}
CryptoKeys::init(&creds, &user_secrets, &password).await?;
println!("");
println!("Cryptographic key setup is complete.");
println!("");
println!("If you are using the static login provider, add the following section to your .toml configuration file:");
println!("");
dump_config(&password, &creds);
}
Command::InitializeLocalKeys { creds } => {
let creds = make_storage_creds(creds);
println!("Please enter a password for local IMAP access.");
println!("This password is not used for key decryption, your keys will be printed below (do not lose them!)");
println!(
"If you plan on using LDAP login, stop right here and use `first-login` instead"
);
let password = rpassword::prompt_password("Enter password: ")?;
let password_confirm = rpassword::prompt_password("Confirm password: ")?;
if password != password_confirm {
bail!("Passwords don't match.");
CompanionCommand::Reload { pid } => reload(*pid, config.pid)?,
CompanionCommand::Wizard => {
unimplemented!();
}
let master = gen_key();
let (_, secret) = gen_keypair();
let keys = CryptoKeys::init_without_password(&creds, &master, &secret).await?;
println!("");
println!("Cryptographic key setup is complete.");
println!("");
println!("Add the following section to your .toml configuration file:");
println!("");
dump_config(&password, &creds);
dump_keys(&keys);
CompanionCommand::Account(cmd) => {
let user_file = config.users.user_list;
account_management(&args.command, cmd, user_file)?;
}
},
(Command::Provider(subcommand), AnyConfig::Provider(config)) => match subcommand {
ProviderCommand::Daemon => {
let server = Server::from_provider_config(config).await?;
server.run().await?;
}
ProviderCommand::Reload { pid } => reload(*pid, config.pid)?,
ProviderCommand::Account(cmd) => {
let user_file = match config.users {
UserManagement::Static(conf) => conf.user_list,
_ => {
panic!("Only static account management is supported from Aerogramme.")
}
};
account_management(&args.command, cmd, user_file)?;
}
},
(Command::Provider(_), AnyConfig::Companion(_)) => {
bail!("Your want to run a 'Provider' command but your configuration file has role 'Companion'.");
}
Command::AddPassword {
creds,
user_secrets,
gen,
} => {
let creds = make_storage_creds(creds);
let user_secrets = make_user_secrets(user_secrets);
let existing_password =
rpassword::prompt_password("Enter existing password to decrypt keys: ")?;
let new_password = if gen {
let password = base64::encode_config(
&u128::to_be_bytes(thread_rng().gen())[..10],
base64::URL_SAFE_NO_PAD,
);
println!("Your new password: {}", password);
println!("Keep it safe!");
password
} else {
let password = rpassword::prompt_password("Enter new password: ")?;
let password_confirm = rpassword::prompt_password("Confirm new password: ")?;
if password != password_confirm {
bail!("Passwords don't match.");
(Command::Companion(_), AnyConfig::Provider(_)) => {
bail!("Your want to run a 'Companion' command but your configuration file has role 'Provider'.");
}
(Command::Tools(subcommand), _) => match subcommand {
ToolsCommand::PasswordHash { maybe_password } => {
let password = match maybe_password {
Some(pwd) => pwd.clone(),
None => rpassword::prompt_password("Enter password: ")?,
};
println!("{}", hash_password(&password)?);
}
ToolsCommand::CryptoRoot(crcommand) => match crcommand {
CryptoRootCommand::New { maybe_password } => {
let password = match maybe_password {
Some(pwd) => pwd.clone(),
None => {
let password = rpassword::prompt_password("Enter password: ")?;
let password_confirm =
rpassword::prompt_password("Confirm password: ")?;
if password != password_confirm {
bail!("Passwords don't match.");
}
password
}
};
let crypto_keys = CryptoKeys::init();
let cr = CryptoRoot::create_pass(&password, &crypto_keys)?;
println!("{}", cr.0);
}
password
};
CryptoRootCommand::NewClearText => {
let crypto_keys = CryptoKeys::init();
let cr = CryptoRoot::create_cleartext(&crypto_keys);
println!("{}", cr.0);
}
CryptoRootCommand::ChangePassword {
maybe_old_password,
maybe_new_password,
crypto_root,
} => {
let old_password = match maybe_old_password {
Some(pwd) => pwd.to_string(),
None => rpassword::prompt_password("Enter old password: ")?,
};
let keys = CryptoKeys::open(&creds, &user_secrets, &existing_password).await?;
keys.add_password(&creds, &user_secrets, &new_password)
.await?;
println!("");
println!("New password added successfully.");
}
Command::DeletePassword {
creds,
user_secrets,
allow_delete_all,
} => {
let creds = make_storage_creds(creds);
let user_secrets = make_user_secrets(user_secrets);
let new_password = match maybe_new_password {
Some(pwd) => pwd.to_string(),
None => {
let password = rpassword::prompt_password("Enter new password: ")?;
let password_confirm =
rpassword::prompt_password("Confirm new password: ")?;
if password != password_confirm {
bail!("Passwords don't match.");
}
password
}
};
let existing_password = rpassword::prompt_password("Enter password to delete: ")?;
let keys = match allow_delete_all {
true => Some(CryptoKeys::open(&creds, &user_secrets, &existing_password).await?),
false => None,
};
CryptoKeys::delete_password(&creds, &existing_password, allow_delete_all).await?;
println!("");
println!("Password was deleted successfully.");
if let Some(keys) = keys {
println!("As a reminder, here are your cryptographic keys:");
dump_keys(&keys);
}
}
Command::ShowKeys {
creds,
user_secrets,
} => {
let creds = make_storage_creds(creds);
let user_secrets = make_user_secrets(user_secrets);
let existing_password = rpassword::prompt_password("Enter key decryption password: ")?;
let keys = CryptoKeys::open(&creds, &user_secrets, &existing_password).await?;
dump_keys(&keys);
}
let keys = CryptoRoot(crypto_root.to_string()).crypto_keys(&old_password)?;
let cr = CryptoRoot::create_pass(&new_password, &keys)?;
println!("{}", cr.0);
}
CryptoRootCommand::DeriveIncoming { crypto_root } => {
let pubkey = CryptoRoot(crypto_root.to_string()).public_key()?;
let cr = CryptoRoot::create_incoming(&pubkey);
println!("{}", cr.0);
}
},
},
}
Ok(())
}
fn make_storage_creds(c: StorageCredsArgs) -> StorageCredentials {
let s3_region = Region {
name: c.region.clone(),
endpoint: c.s3_endpoint,
fn reload(pid: Option<i32>, pid_path: Option<PathBuf>) -> Result<()> {
let final_pid = match (pid, pid_path) {
(Some(pid), _) => pid,
(_, Some(path)) => {
let mut f = std::fs::OpenOptions::new().read(true).open(path)?;
let mut pidstr = String::new();
f.read_to_string(&mut pidstr)?;
pidstr.parse::<i32>()?
}
_ => bail!("Unable to infer your daemon's PID"),
};
let k2v_region = Region {
name: c.region,
endpoint: c.k2v_endpoint,
let pid = Pid::from_raw(final_pid);
signal::kill(pid, signal::Signal::SIGUSR1)?;
Ok(())
}
fn account_management(root: &Command, cmd: &AccountManagement, users: PathBuf) -> Result<()> {
let mut ulist: UserList =
read_config(users.clone()).context(format!("'{:?}' must be a user database", users))?;
match cmd {
AccountManagement::Add { login, setup } => {
tracing::debug!(user = login, "will-create");
let stp: SetupEntry = read_config(setup.clone())
.context(format!("'{:?}' must be a setup file", setup))?;
tracing::debug!(user = login, "loaded setup entry");
let password = match stp.clear_password {
Some(pwd) => pwd,
None => {
let password = rpassword::prompt_password("Enter password: ")?;
let password_confirm = rpassword::prompt_password("Confirm password: ")?;
if password != password_confirm {
bail!("Passwords don't match.");
}
password
}
};
let crypto_keys = CryptoKeys::init();
let crypto_root = match root {
Command::Provider(_) => CryptoRoot::create_pass(&password, &crypto_keys)?,
Command::Companion(_) => CryptoRoot::create_cleartext(&crypto_keys),
_ => unreachable!(),
};
let hash = hash_password(password.as_str()).context("unable to hash password")?;
ulist.insert(
login.clone(),
UserEntry {
email_addresses: stp.email_addresses,
password: hash,
crypto_root: crypto_root.0,
storage: stp.storage,
},
);
write_config(users.clone(), &ulist)?;
}
AccountManagement::Delete { login } => {
tracing::debug!(user = login, "will-delete");
ulist.remove(login);
write_config(users.clone(), &ulist)?;
}
AccountManagement::ChangePassword {
maybe_old_password,
maybe_new_password,
login,
} => {
let mut user = ulist.remove(login).context("user must exist first")?;
let old_password = match maybe_old_password {
Some(pwd) => pwd.to_string(),
None => rpassword::prompt_password("Enter old password: ")?,
};
if !verify_password(&old_password, &user.password)? {
bail!(format!("invalid password for login {}", login));
}
let crypto_keys = CryptoRoot(user.crypto_root).crypto_keys(&old_password)?;
let new_password = match maybe_new_password {
Some(pwd) => pwd.to_string(),
None => {
let password = rpassword::prompt_password("Enter new password: ")?;
let password_confirm = rpassword::prompt_password("Confirm new password: ")?;
if password != password_confirm {
bail!("Passwords don't match.");
}
password
}
};
let new_hash = hash_password(&new_password)?;
let new_crypto_root = CryptoRoot::create_pass(&new_password, &crypto_keys)?;
user.password = new_hash;
user.crypto_root = new_crypto_root.0;
ulist.insert(login.clone(), user);
write_config(users.clone(), &ulist)?;
}
};
StorageCredentials {
k2v_region,
s3_region,
aws_access_key_id: c.aws_access_key_id,
aws_secret_access_key: c.aws_secret_access_key,
bucket: c.bucket,
}
}
fn make_user_secrets(c: UserSecretsArgs) -> UserSecrets {
UserSecrets {
user_secret: c.user_secret,
alternate_user_secrets: c
.alternate_user_secrets
.split(',')
.map(|x| x.trim())
.filter(|x| !x.is_empty())
.map(|x| x.to_string())
.collect(),
}
}
fn dump_config(password: &str, creds: &StorageCredentials) {
println!("[login_static.users.<username>]");
println!(
"password = \"{}\"",
hash_password(password).expect("unable to hash password")
);
println!("aws_access_key_id = \"{}\"", creds.aws_access_key_id);
println!(
"aws_secret_access_key = \"{}\"",
creds.aws_secret_access_key
);
}
fn dump_keys(keys: &CryptoKeys) {
println!("master_key = \"{}\"", base64::encode(&keys.master));
println!("secret_key = \"{}\"", base64::encode(&keys.secret));
Ok(())
}

View file

@ -1,39 +1,86 @@
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{bail, Result};
use anyhow::Result;
use futures::try_join;
use log::*;
use tokio::sync::watch;
use crate::auth;
use crate::config::*;
use crate::imap;
use crate::lmtp::*;
use crate::login::ArcLoginProvider;
use crate::login::{ldap_provider::*, static_provider::*, Region};
use crate::login::{demo_provider::*, ldap_provider::*, static_provider::*};
pub struct Server {
lmtp_server: Option<Arc<LmtpServer>>,
imap_unsecure_server: Option<imap::Server>,
imap_server: Option<imap::Server>,
auth_server: Option<auth::AuthServer>,
pid_file: Option<PathBuf>,
}
impl Server {
pub async fn new(config: Config) -> Result<Self> {
let (login, lmtp_conf, imap_conf) = build(config)?;
pub async fn from_companion_config(config: CompanionConfig) -> Result<Self> {
tracing::info!("Init as companion");
let login = Arc::new(StaticLoginProvider::new(config.users).await?);
let lmtp_server = lmtp_conf.map(|cfg| LmtpServer::new(cfg, login.clone()));
let imap_server = match imap_conf {
Some(cfg) => Some(imap::new(cfg, login.clone()).await?),
None => None,
let lmtp_server = None;
let imap_unsecure_server = Some(imap::new_unsecure(config.imap, login.clone()));
Ok(Self {
lmtp_server,
imap_unsecure_server,
imap_server: None,
auth_server: None,
pid_file: config.pid,
})
}
pub async fn from_provider_config(config: ProviderConfig) -> Result<Self> {
tracing::info!("Init as provider");
let login: ArcLoginProvider = match config.users {
UserManagement::Demo => Arc::new(DemoLoginProvider::new()),
UserManagement::Static(x) => Arc::new(StaticLoginProvider::new(x).await?),
UserManagement::Ldap(x) => Arc::new(LdapLoginProvider::new(x)?),
};
let lmtp_server = config.lmtp.map(|lmtp| LmtpServer::new(lmtp, login.clone()));
let imap_unsecure_server = config
.imap_unsecure
.map(|imap| imap::new_unsecure(imap, login.clone()));
let imap_server = config
.imap
.map(|imap| imap::new(imap, login.clone()))
.transpose()?;
let auth_server = config
.auth
.map(|auth| auth::AuthServer::new(auth, login.clone()));
Ok(Self {
lmtp_server,
imap_unsecure_server,
imap_server,
auth_server,
pid_file: config.pid,
})
}
pub async fn run(self) -> Result<()> {
tracing::info!("Starting Aerogramme...");
let pid = std::process::id();
tracing::info!(pid = pid, "Starting main loops");
// write the pid file
if let Some(pid_file) = self.pid_file {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(pid_file)?;
file.write_all(pid.to_string().as_bytes())?;
drop(file);
}
let (exit_signal, provoke_exit) = watch_ctrl_c();
let _exit_on_err = move |err: anyhow::Error| {
@ -48,11 +95,23 @@ impl Server {
Some(s) => s.run(exit_signal.clone()).await,
}
},
async {
match self.imap_unsecure_server {
None => Ok(()),
Some(s) => s.run(exit_signal.clone()).await,
}
},
async {
match self.imap_server {
None => Ok(()),
Some(s) => s.run(exit_signal.clone()).await,
}
},
async {
match self.auth_server {
None => Ok(()),
Some(a) => a.run(exit_signal.clone()).await,
}
}
)?;
@ -60,28 +119,6 @@ impl Server {
}
}
fn build(config: Config) -> Result<(ArcLoginProvider, Option<LmtpConfig>, Option<ImapConfig>)> {
let s3_region = Region {
name: config.aws_region.clone(),
endpoint: config.s3_endpoint,
};
let k2v_region = Region {
name: config.aws_region,
endpoint: config.k2v_endpoint,
};
let lp: ArcLoginProvider = match (config.login_static, config.login_ldap) {
(Some(st), None) => Arc::new(StaticLoginProvider::new(st, k2v_region, s3_region)?),
(None, Some(ld)) => Arc::new(LdapLoginProvider::new(ld, k2v_region, s3_region)?),
(Some(_), Some(_)) => {
bail!("A single login provider must be set up in config file")
}
(None, None) => bail!("No login provider is set up in config file"),
};
Ok((lp, config.lmtp, config.imap))
}
pub fn watch_ctrl_c() -> (watch::Receiver<bool>, Arc<watch::Sender<bool>>) {
let (send_cancel, watch_cancel) = watch::channel(false);
let send_cancel = Arc::new(send_cancel);

538
src/storage/garage.rs Normal file
View file

@ -0,0 +1,538 @@
use aws_sdk_s3::{self as s3, error::SdkError, operation::get_object::GetObjectError};
use aws_smithy_runtime::client::http::hyper_014::HyperClientBuilder;
use aws_smithy_runtime_api::client::http::SharedHttpClient;
use hyper_rustls::HttpsConnector;
use hyper_util::client::legacy::{connect::HttpConnector, Client as HttpClient};
use hyper_util::rt::TokioExecutor;
use serde::Serialize;
use crate::storage::*;
pub struct GarageRoot {
k2v_http: HttpClient<HttpsConnector<HttpConnector>, k2v_client::Body>,
aws_http: SharedHttpClient,
}
impl GarageRoot {
pub fn new() -> anyhow::Result<Self> {
let connector = hyper_rustls::HttpsConnectorBuilder::new()
.with_native_roots()?
.https_or_http()
.enable_http1()
.enable_http2()
.build();
let k2v_http = HttpClient::builder(TokioExecutor::new()).build(connector);
let aws_http = HyperClientBuilder::new().build_https();
Ok(Self { k2v_http, aws_http })
}
pub fn user(&self, conf: GarageConf) -> anyhow::Result<Arc<GarageUser>> {
let mut unicity: Vec<u8> = vec![];
unicity.extend_from_slice(file!().as_bytes());
unicity.append(&mut rmp_serde::to_vec(&conf)?);
Ok(Arc::new(GarageUser {
conf,
aws_http: self.aws_http.clone(),
k2v_http: self.k2v_http.clone(),
unicity,
}))
}
}
#[derive(Clone, Debug, Serialize)]
pub struct GarageConf {
pub region: String,
pub s3_endpoint: String,
pub k2v_endpoint: String,
pub aws_access_key_id: String,
pub aws_secret_access_key: String,
pub bucket: String,
}
//@FIXME we should get rid of this builder
//and allocate a S3 + K2V client only once per user
//(and using a shared HTTP client)
#[derive(Clone, Debug)]
pub struct GarageUser {
conf: GarageConf,
aws_http: SharedHttpClient,
k2v_http: HttpClient<HttpsConnector<HttpConnector>, k2v_client::Body>,
unicity: Vec<u8>,
}
#[async_trait]
impl IBuilder for GarageUser {
async fn build(&self) -> Result<Store, StorageError> {
let s3_creds = s3::config::Credentials::new(
self.conf.aws_access_key_id.clone(),
self.conf.aws_secret_access_key.clone(),
None,
None,
"aerogramme",
);
let sdk_config = aws_config::from_env()
.region(aws_config::Region::new(self.conf.region.clone()))
.credentials_provider(s3_creds)
.http_client(self.aws_http.clone())
.endpoint_url(self.conf.s3_endpoint.clone())
.load()
.await;
let s3_config = aws_sdk_s3::config::Builder::from(&sdk_config)
.force_path_style(true)
.build();
let s3_client = aws_sdk_s3::Client::from_conf(s3_config);
let k2v_config = k2v_client::K2vClientConfig {
endpoint: self.conf.k2v_endpoint.clone(),
region: self.conf.region.clone(),
aws_access_key_id: self.conf.aws_access_key_id.clone(),
aws_secret_access_key: self.conf.aws_secret_access_key.clone(),
bucket: self.conf.bucket.clone(),
user_agent: None,
};
let k2v_client =
match k2v_client::K2vClient::new_with_client(k2v_config, self.k2v_http.clone()) {
Err(e) => {
tracing::error!("unable to build k2v client: {}", e);
return Err(StorageError::Internal);
}
Ok(v) => v,
};
Ok(Box::new(GarageStore {
bucket: self.conf.bucket.clone(),
s3: s3_client,
k2v: k2v_client,
}))
}
fn unique(&self) -> UnicityBuffer {
UnicityBuffer(self.unicity.clone())
}
}
pub struct GarageStore {
bucket: String,
s3: s3::Client,
k2v: k2v_client::K2vClient,
}
fn causal_to_row_val(row_ref: RowRef, causal_value: k2v_client::CausalValue) -> RowVal {
let new_row_ref = row_ref.with_causality(causal_value.causality.into());
let row_values = causal_value
.value
.into_iter()
.map(|k2v_value| match k2v_value {
k2v_client::K2vValue::Tombstone => Alternative::Tombstone,
k2v_client::K2vValue::Value(v) => Alternative::Value(v),
})
.collect::<Vec<_>>();
RowVal {
row_ref: new_row_ref,
value: row_values,
}
}
#[async_trait]
impl IStore for GarageStore {
async fn row_fetch<'a>(&self, select: &Selector<'a>) -> Result<Vec<RowVal>, StorageError> {
tracing::trace!(select=%select, command="row_fetch");
let (pk_list, batch_op) = match select {
Selector::Range {
shard,
sort_begin,
sort_end,
} => (
vec![shard.to_string()],
vec![k2v_client::BatchReadOp {
partition_key: shard,
filter: k2v_client::Filter {
start: Some(sort_begin),
end: Some(sort_end),
..k2v_client::Filter::default()
},
..k2v_client::BatchReadOp::default()
}],
),
Selector::List(row_ref_list) => (
row_ref_list
.iter()
.map(|row_ref| row_ref.uid.shard.to_string())
.collect::<Vec<_>>(),
row_ref_list
.iter()
.map(|row_ref| k2v_client::BatchReadOp {
partition_key: &row_ref.uid.shard,
filter: k2v_client::Filter {
start: Some(&row_ref.uid.sort),
..k2v_client::Filter::default()
},
single_item: true,
..k2v_client::BatchReadOp::default()
})
.collect::<Vec<_>>(),
),
Selector::Prefix { shard, sort_prefix } => (
vec![shard.to_string()],
vec![k2v_client::BatchReadOp {
partition_key: shard,
filter: k2v_client::Filter {
prefix: Some(sort_prefix),
..k2v_client::Filter::default()
},
..k2v_client::BatchReadOp::default()
}],
),
Selector::Single(row_ref) => {
let causal_value = match self
.k2v
.read_item(&row_ref.uid.shard, &row_ref.uid.sort)
.await
{
Err(k2v_client::Error::NotFound) => {
tracing::debug!(
"K2V item not found shard={}, sort={}, bucket={}",
row_ref.uid.shard,
row_ref.uid.sort,
self.bucket,
);
return Err(StorageError::NotFound);
}
Err(e) => {
tracing::error!(
"K2V read item shard={}, sort={}, bucket={} failed: {}",
row_ref.uid.shard,
row_ref.uid.sort,
self.bucket,
e
);
return Err(StorageError::Internal);
}
Ok(v) => v,
};
let row_val = causal_to_row_val((*row_ref).clone(), causal_value);
return Ok(vec![row_val]);
}
};
let all_raw_res = match self.k2v.read_batch(&batch_op).await {
Err(e) => {
tracing::error!(
"k2v read batch failed for {:?}, bucket {} with err: {}",
select,
self.bucket,
e
);
return Err(StorageError::Internal);
}
Ok(v) => v,
};
//println!("fetch res -> {:?}", all_raw_res);
let row_vals =
all_raw_res
.into_iter()
.zip(pk_list.into_iter())
.fold(vec![], |mut acc, (page, pk)| {
page.items
.into_iter()
.map(|(sk, cv)| causal_to_row_val(RowRef::new(&pk, &sk), cv))
.for_each(|rr| acc.push(rr));
acc
});
tracing::debug!(fetch_count = row_vals.len(), command = "row_fetch");
Ok(row_vals)
}
async fn row_rm<'a>(&self, select: &Selector<'a>) -> Result<(), StorageError> {
tracing::trace!(select=%select, command="row_rm");
let del_op = match select {
Selector::Range {
shard,
sort_begin,
sort_end,
} => vec![k2v_client::BatchDeleteOp {
partition_key: shard,
prefix: None,
start: Some(sort_begin),
end: Some(sort_end),
single_item: false,
}],
Selector::List(row_ref_list) => {
// Insert null values with causality token = delete
let batch_op = row_ref_list
.iter()
.map(|v| k2v_client::BatchInsertOp {
partition_key: &v.uid.shard,
sort_key: &v.uid.sort,
causality: v.causality.clone().map(|ct| ct.into()),
value: k2v_client::K2vValue::Tombstone,
})
.collect::<Vec<_>>();
return match self.k2v.insert_batch(&batch_op).await {
Err(e) => {
tracing::error!("Unable to delete the list of values: {}", e);
Err(StorageError::Internal)
}
Ok(_) => Ok(()),
};
}
Selector::Prefix { shard, sort_prefix } => vec![k2v_client::BatchDeleteOp {
partition_key: shard,
prefix: Some(sort_prefix),
start: None,
end: None,
single_item: false,
}],
Selector::Single(row_ref) => {
// Insert null values with causality token = delete
let batch_op = vec![k2v_client::BatchInsertOp {
partition_key: &row_ref.uid.shard,
sort_key: &row_ref.uid.sort,
causality: row_ref.causality.clone().map(|ct| ct.into()),
value: k2v_client::K2vValue::Tombstone,
}];
return match self.k2v.insert_batch(&batch_op).await {
Err(e) => {
tracing::error!("Unable to delete the list of values: {}", e);
Err(StorageError::Internal)
}
Ok(_) => Ok(()),
};
}
};
// Finally here we only have prefix & range
match self.k2v.delete_batch(&del_op).await {
Err(e) => {
tracing::error!("delete batch error: {}", e);
Err(StorageError::Internal)
}
Ok(_) => Ok(()),
}
}
async fn row_insert(&self, values: Vec<RowVal>) -> Result<(), StorageError> {
tracing::trace!(entries=%values.iter().map(|v| v.row_ref.to_string()).collect::<Vec<_>>().join(","), command="row_insert");
let batch_ops = values
.iter()
.map(|v| k2v_client::BatchInsertOp {
partition_key: &v.row_ref.uid.shard,
sort_key: &v.row_ref.uid.sort,
causality: v.row_ref.causality.clone().map(|ct| ct.into()),
value: v
.value
.iter()
.next()
.map(|cv| match cv {
Alternative::Value(buff) => k2v_client::K2vValue::Value(buff.clone()),
Alternative::Tombstone => k2v_client::K2vValue::Tombstone,
})
.unwrap_or(k2v_client::K2vValue::Tombstone),
})
.collect::<Vec<_>>();
match self.k2v.insert_batch(&batch_ops).await {
Err(e) => {
tracing::error!("k2v can't insert some value: {}", e);
Err(StorageError::Internal)
}
Ok(v) => Ok(v),
}
}
async fn row_poll(&self, value: &RowRef) -> Result<RowVal, StorageError> {
tracing::trace!(entry=%value, command="row_poll");
loop {
if let Some(ct) = &value.causality {
match self
.k2v
.poll_item(&value.uid.shard, &value.uid.sort, ct.clone().into(), None)
.await
{
Err(e) => {
tracing::error!("Unable to poll item: {}", e);
return Err(StorageError::Internal);
}
Ok(None) => continue,
Ok(Some(cv)) => return Ok(causal_to_row_val(value.clone(), cv)),
}
} else {
match self.k2v.read_item(&value.uid.shard, &value.uid.sort).await {
Err(k2v_client::Error::NotFound) => {
self.k2v
.insert_item(&value.uid.shard, &value.uid.sort, vec![0u8], None)
.await
.map_err(|e| {
tracing::error!("Unable to insert item in polling logic: {}", e);
StorageError::Internal
})?;
}
Err(e) => {
tracing::error!("Unable to read item in polling logic: {}", e);
return Err(StorageError::Internal);
}
Ok(cv) => return Ok(causal_to_row_val(value.clone(), cv)),
}
}
}
}
async fn blob_fetch(&self, blob_ref: &BlobRef) -> Result<BlobVal, StorageError> {
tracing::trace!(entry=%blob_ref, command="blob_fetch");
let maybe_out = self
.s3
.get_object()
.bucket(self.bucket.to_string())
.key(blob_ref.0.to_string())
.send()
.await;
let object_output = match maybe_out {
Ok(output) => output,
Err(SdkError::ServiceError(x)) => match x.err() {
GetObjectError::NoSuchKey(_) => return Err(StorageError::NotFound),
e => {
tracing::warn!("Blob Fetch Error, Service Error: {}", e);
return Err(StorageError::Internal);
}
},
Err(e) => {
tracing::warn!("Blob Fetch Error, {}", e);
return Err(StorageError::Internal);
}
};
let buffer = match object_output.body.collect().await {
Ok(aggreg) => aggreg.to_vec(),
Err(e) => {
tracing::warn!("Fetching body failed with {}", e);
return Err(StorageError::Internal);
}
};
let mut bv = BlobVal::new(blob_ref.clone(), buffer);
if let Some(meta) = object_output.metadata {
bv.meta = meta;
}
tracing::debug!("Fetched {}/{}", self.bucket, blob_ref.0);
Ok(bv)
}
async fn blob_insert(&self, blob_val: BlobVal) -> Result<(), StorageError> {
tracing::trace!(entry=%blob_val.blob_ref, command="blob_insert");
let streamable_value = s3::primitives::ByteStream::from(blob_val.value);
let maybe_send = self
.s3
.put_object()
.bucket(self.bucket.to_string())
.key(blob_val.blob_ref.0.to_string())
.set_metadata(Some(blob_val.meta))
.body(streamable_value)
.send()
.await;
match maybe_send {
Err(e) => {
tracing::error!("unable to send object: {}", e);
Err(StorageError::Internal)
}
Ok(_) => {
tracing::debug!("Inserted {}/{}", self.bucket, blob_val.blob_ref.0);
Ok(())
}
}
}
async fn blob_copy(&self, src: &BlobRef, dst: &BlobRef) -> Result<(), StorageError> {
tracing::trace!(src=%src, dst=%dst, command="blob_copy");
let maybe_copy = self
.s3
.copy_object()
.bucket(self.bucket.to_string())
.key(dst.0.clone())
.copy_source(format!("/{}/{}", self.bucket.to_string(), src.0.clone()))
.send()
.await;
match maybe_copy {
Err(e) => {
tracing::error!(
"unable to copy object {} to {} (bucket: {}), error: {}",
src.0,
dst.0,
self.bucket,
e
);
Err(StorageError::Internal)
}
Ok(_) => {
tracing::debug!("copied {} to {} (bucket: {})", src.0, dst.0, self.bucket);
Ok(())
}
}
}
async fn blob_list(&self, prefix: &str) -> Result<Vec<BlobRef>, StorageError> {
tracing::trace!(prefix = prefix, command = "blob_list");
let maybe_list = self
.s3
.list_objects_v2()
.bucket(self.bucket.to_string())
.prefix(prefix)
.into_paginator()
.send()
.try_collect()
.await;
match maybe_list {
Err(e) => {
tracing::error!(
"listing prefix {} on bucket {} failed: {}",
prefix,
self.bucket,
e
);
Err(StorageError::Internal)
}
Ok(pagin_list_out) => Ok(pagin_list_out
.into_iter()
.map(|list_out| list_out.contents.unwrap_or(vec![]))
.flatten()
.map(|obj| BlobRef(obj.key.unwrap_or(String::new())))
.collect::<Vec<_>>()),
}
}
async fn blob_rm(&self, blob_ref: &BlobRef) -> Result<(), StorageError> {
tracing::trace!(entry=%blob_ref, command="blob_rm");
let maybe_delete = self
.s3
.delete_object()
.bucket(self.bucket.to_string())
.key(blob_ref.0.clone())
.send()
.await;
match maybe_delete {
Err(e) => {
tracing::error!(
"unable to delete {} (bucket: {}), error {}",
blob_ref.0,
self.bucket,
e
);
Err(StorageError::Internal)
}
Ok(_) => {
tracing::debug!("deleted {} (bucket: {})", blob_ref.0, self.bucket);
Ok(())
}
}
}
}

334
src/storage/in_memory.rs Normal file
View file

@ -0,0 +1,334 @@
use crate::storage::*;
use std::collections::{BTreeMap, HashMap};
use std::ops::Bound::{self, Excluded, Included, Unbounded};
use std::sync::{Arc, RwLock};
use tokio::sync::Notify;
/// This implementation is very inneficient, and not completely correct
/// Indeed, when the connector is dropped, the memory is freed.
/// It means that when a user disconnects, its data are lost.
/// It's intended only for basic debugging, do not use it for advanced tests...
#[derive(Debug, Default)]
pub struct MemDb(tokio::sync::Mutex<HashMap<String, Arc<MemBuilder>>>);
impl MemDb {
pub fn new() -> Self {
Self(tokio::sync::Mutex::new(HashMap::new()))
}
pub async fn builder(&self, username: &str) -> Arc<MemBuilder> {
let mut global_storage = self.0.lock().await;
global_storage
.entry(username.to_string())
.or_insert(MemBuilder::new(username))
.clone()
}
}
#[derive(Debug, Clone)]
enum InternalData {
Tombstone,
Value(Vec<u8>),
}
impl InternalData {
fn to_alternative(&self) -> Alternative {
match self {
Self::Tombstone => Alternative::Tombstone,
Self::Value(x) => Alternative::Value(x.clone()),
}
}
}
#[derive(Debug)]
struct InternalRowVal {
data: Vec<InternalData>,
version: u64,
change: Arc<Notify>,
}
impl std::default::Default for InternalRowVal {
fn default() -> Self {
Self {
data: vec![],
version: 1,
change: Arc::new(Notify::new()),
}
}
}
impl InternalRowVal {
fn concurrent_values(&self) -> Vec<Alternative> {
self.data.iter().map(InternalData::to_alternative).collect()
}
fn to_row_val(&self, row_ref: RowRef) -> RowVal {
RowVal {
row_ref: row_ref.with_causality(self.version.to_string()),
value: self.concurrent_values(),
}
}
}
#[derive(Debug, Default, Clone)]
struct InternalBlobVal {
data: Vec<u8>,
metadata: HashMap<String, String>,
}
impl InternalBlobVal {
fn to_blob_val(&self, bref: &BlobRef) -> BlobVal {
BlobVal {
blob_ref: bref.clone(),
meta: self.metadata.clone(),
value: self.data.clone(),
}
}
}
type ArcRow = Arc<RwLock<HashMap<String, BTreeMap<String, InternalRowVal>>>>;
type ArcBlob = Arc<RwLock<BTreeMap<String, InternalBlobVal>>>;
#[derive(Clone, Debug)]
pub struct MemBuilder {
unicity: Vec<u8>,
row: ArcRow,
blob: ArcBlob,
}
impl MemBuilder {
pub fn new(user: &str) -> Arc<Self> {
tracing::debug!("initialize membuilder for {}", user);
let mut unicity: Vec<u8> = vec![];
unicity.extend_from_slice(file!().as_bytes());
unicity.extend_from_slice(user.as_bytes());
Arc::new(Self {
unicity,
row: Arc::new(RwLock::new(HashMap::new())),
blob: Arc::new(RwLock::new(BTreeMap::new())),
})
}
}
#[async_trait]
impl IBuilder for MemBuilder {
async fn build(&self) -> Result<Store, StorageError> {
Ok(Box::new(MemStore {
row: self.row.clone(),
blob: self.blob.clone(),
}))
}
fn unique(&self) -> UnicityBuffer {
UnicityBuffer(self.unicity.clone())
}
}
pub struct MemStore {
row: ArcRow,
blob: ArcBlob,
}
fn prefix_last_bound(prefix: &str) -> Bound<String> {
let mut sort_end = prefix.to_string();
match sort_end.pop() {
None => Unbounded,
Some(ch) => {
let nc = char::from_u32(ch as u32 + 1).unwrap();
sort_end.push(nc);
Excluded(sort_end)
}
}
}
impl MemStore {
fn row_rm_single(&self, entry: &RowRef) -> Result<(), StorageError> {
tracing::trace!(entry=%entry, command="row_rm_single");
let mut store = self.row.write().or(Err(StorageError::Internal))?;
let shard = &entry.uid.shard;
let sort = &entry.uid.sort;
let cauz = match entry.causality.as_ref().map(|v| v.parse::<u64>()) {
Some(Ok(v)) => v,
_ => 0,
};
let bt = store.entry(shard.to_string()).or_default();
let intval = bt.entry(sort.to_string()).or_default();
if cauz == intval.version {
intval.data.clear();
}
intval.data.push(InternalData::Tombstone);
intval.version += 1;
intval.change.notify_waiters();
Ok(())
}
}
#[async_trait]
impl IStore for MemStore {
async fn row_fetch<'a>(&self, select: &Selector<'a>) -> Result<Vec<RowVal>, StorageError> {
tracing::trace!(select=%select, command="row_fetch");
let store = self.row.read().or(Err(StorageError::Internal))?;
match select {
Selector::Range {
shard,
sort_begin,
sort_end,
} => Ok(store
.get(*shard)
.unwrap_or(&BTreeMap::new())
.range((
Included(sort_begin.to_string()),
Excluded(sort_end.to_string()),
))
.map(|(k, v)| v.to_row_val(RowRef::new(shard, k)))
.collect::<Vec<_>>()),
Selector::List(rlist) => {
let mut acc = vec![];
for row_ref in rlist {
let maybe_intval = store
.get(&row_ref.uid.shard)
.map(|v| v.get(&row_ref.uid.sort))
.flatten();
if let Some(intval) = maybe_intval {
acc.push(intval.to_row_val(row_ref.clone()));
}
}
Ok(acc)
}
Selector::Prefix { shard, sort_prefix } => {
let last_bound = prefix_last_bound(sort_prefix);
Ok(store
.get(*shard)
.unwrap_or(&BTreeMap::new())
.range((Included(sort_prefix.to_string()), last_bound))
.map(|(k, v)| v.to_row_val(RowRef::new(shard, k)))
.collect::<Vec<_>>())
}
Selector::Single(row_ref) => {
let intval = store
.get(&row_ref.uid.shard)
.ok_or(StorageError::NotFound)?
.get(&row_ref.uid.sort)
.ok_or(StorageError::NotFound)?;
Ok(vec![intval.to_row_val((*row_ref).clone())])
}
}
}
async fn row_rm<'a>(&self, select: &Selector<'a>) -> Result<(), StorageError> {
tracing::trace!(select=%select, command="row_rm");
let values = match select {
Selector::Range { .. } | Selector::Prefix { .. } => self
.row_fetch(select)
.await?
.into_iter()
.map(|rv| rv.row_ref)
.collect::<Vec<_>>(),
Selector::List(rlist) => rlist.clone(),
Selector::Single(row_ref) => vec![(*row_ref).clone()],
};
for v in values.into_iter() {
self.row_rm_single(&v)?;
}
Ok(())
}
async fn row_insert(&self, values: Vec<RowVal>) -> Result<(), StorageError> {
tracing::trace!(entries=%values.iter().map(|v| v.row_ref.to_string()).collect::<Vec<_>>().join(","), command="row_insert");
let mut store = self.row.write().or(Err(StorageError::Internal))?;
for v in values.into_iter() {
let shard = v.row_ref.uid.shard;
let sort = v.row_ref.uid.sort;
let val = match v.value.into_iter().next() {
Some(Alternative::Value(x)) => x,
_ => vec![],
};
let cauz = match v.row_ref.causality.map(|v| v.parse::<u64>()) {
Some(Ok(v)) => v,
_ => 0,
};
let bt = store.entry(shard).or_default();
let intval = bt.entry(sort).or_default();
if cauz == intval.version {
intval.data.clear();
}
intval.data.push(InternalData::Value(val));
intval.version += 1;
intval.change.notify_waiters();
}
Ok(())
}
async fn row_poll(&self, value: &RowRef) -> Result<RowVal, StorageError> {
tracing::trace!(entry=%value, command="row_poll");
let shard = &value.uid.shard;
let sort = &value.uid.sort;
let cauz = match value.causality.as_ref().map(|v| v.parse::<u64>()) {
Some(Ok(v)) => v,
_ => 0,
};
let notify_me = {
let mut store = self.row.write().or(Err(StorageError::Internal))?;
let bt = store.entry(shard.to_string()).or_default();
let intval = bt.entry(sort.to_string()).or_default();
if intval.version != cauz {
return Ok(intval.to_row_val(value.clone()));
}
intval.change.clone()
};
notify_me.notified().await;
let res = self.row_fetch(&Selector::Single(value)).await?;
res.into_iter().next().ok_or(StorageError::NotFound)
}
async fn blob_fetch(&self, blob_ref: &BlobRef) -> Result<BlobVal, StorageError> {
tracing::trace!(entry=%blob_ref, command="blob_fetch");
let store = self.blob.read().or(Err(StorageError::Internal))?;
store
.get(&blob_ref.0)
.ok_or(StorageError::NotFound)
.map(|v| v.to_blob_val(blob_ref))
}
async fn blob_insert(&self, blob_val: BlobVal) -> Result<(), StorageError> {
tracing::trace!(entry=%blob_val.blob_ref, command="blob_insert");
let mut store = self.blob.write().or(Err(StorageError::Internal))?;
let entry = store.entry(blob_val.blob_ref.0.clone()).or_default();
entry.data = blob_val.value.clone();
entry.metadata = blob_val.meta.clone();
Ok(())
}
async fn blob_copy(&self, src: &BlobRef, dst: &BlobRef) -> Result<(), StorageError> {
tracing::trace!(src=%src, dst=%dst, command="blob_copy");
let mut store = self.blob.write().or(Err(StorageError::Internal))?;
let blob_src = store.entry(src.0.clone()).or_default().clone();
store.insert(dst.0.clone(), blob_src);
Ok(())
}
async fn blob_list(&self, prefix: &str) -> Result<Vec<BlobRef>, StorageError> {
tracing::trace!(prefix = prefix, command = "blob_list");
let store = self.blob.read().or(Err(StorageError::Internal))?;
let last_bound = prefix_last_bound(prefix);
let blist = store
.range((Included(prefix.to_string()), last_bound))
.map(|(k, _)| BlobRef(k.to_string()))
.collect::<Vec<_>>();
Ok(blist)
}
async fn blob_rm(&self, blob_ref: &BlobRef) -> Result<(), StorageError> {
tracing::trace!(entry=%blob_ref, command="blob_rm");
let mut store = self.blob.write().or(Err(StorageError::Internal))?;
store.remove(&blob_ref.0);
Ok(())
}
}

179
src/storage/mod.rs Normal file
View file

@ -0,0 +1,179 @@
/*
*
* This abstraction goal is to leverage all the semantic of Garage K2V+S3,
* to be as tailored as possible to it ; it aims to be a zero-cost abstraction
* compared to when we where directly using the K2V+S3 client.
*
* My idea: we can encapsulate the causality token
* into the object system so it is not exposed.
*/
pub mod garage;
pub mod in_memory;
use async_trait::async_trait;
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub enum Alternative {
Tombstone,
Value(Vec<u8>),
}
type ConcurrentValues = Vec<Alternative>;
#[derive(Debug, Clone)]
pub enum StorageError {
NotFound,
Internal,
}
impl std::fmt::Display for StorageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Storage Error: ")?;
match self {
Self::NotFound => f.write_str("Item not found"),
Self::Internal => f.write_str("An internal error occured"),
}
}
}
impl std::error::Error for StorageError {}
#[derive(Debug, Clone, PartialEq)]
pub struct RowUid {
pub shard: String,
pub sort: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RowRef {
pub uid: RowUid,
pub causality: Option<String>,
}
impl std::fmt::Display for RowRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"RowRef({}, {}, {:?})",
self.uid.shard, self.uid.sort, self.causality
)
}
}
impl RowRef {
pub fn new(shard: &str, sort: &str) -> Self {
Self {
uid: RowUid {
shard: shard.to_string(),
sort: sort.to_string(),
},
causality: None,
}
}
pub fn with_causality(mut self, causality: String) -> Self {
self.causality = Some(causality);
self
}
}
#[derive(Debug, Clone)]
pub struct RowVal {
pub row_ref: RowRef,
pub value: ConcurrentValues,
}
impl RowVal {
pub fn new(row_ref: RowRef, value: Vec<u8>) -> Self {
Self {
row_ref,
value: vec![Alternative::Value(value)],
}
}
}
#[derive(Debug, Clone)]
pub struct BlobRef(pub String);
impl std::fmt::Display for BlobRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "BlobRef({})", self.0)
}
}
#[derive(Debug, Clone)]
pub struct BlobVal {
pub blob_ref: BlobRef,
pub meta: HashMap<String, String>,
pub value: Vec<u8>,
}
impl BlobVal {
pub fn new(blob_ref: BlobRef, value: Vec<u8>) -> Self {
Self {
blob_ref,
value,
meta: HashMap::new(),
}
}
pub fn with_meta(mut self, k: String, v: String) -> Self {
self.meta.insert(k, v);
self
}
}
#[derive(Debug)]
pub enum Selector<'a> {
Range {
shard: &'a str,
sort_begin: &'a str,
sort_end: &'a str,
},
List(Vec<RowRef>), // list of (shard_key, sort_key)
#[allow(dead_code)]
Prefix {
shard: &'a str,
sort_prefix: &'a str,
},
Single(&'a RowRef),
}
impl<'a> std::fmt::Display for Selector<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Range {
shard,
sort_begin,
sort_end,
} => write!(f, "Range({}, [{}, {}[)", shard, sort_begin, sort_end),
Self::List(list) => write!(f, "List({:?})", list),
Self::Prefix { shard, sort_prefix } => write!(f, "Prefix({}, {})", shard, sort_prefix),
Self::Single(row_ref) => write!(f, "Single({})", row_ref),
}
}
}
#[async_trait]
pub trait IStore {
async fn row_fetch<'a>(&self, select: &Selector<'a>) -> Result<Vec<RowVal>, StorageError>;
async fn row_rm<'a>(&self, select: &Selector<'a>) -> Result<(), StorageError>;
async fn row_insert(&self, values: Vec<RowVal>) -> Result<(), StorageError>;
async fn row_poll(&self, value: &RowRef) -> Result<RowVal, StorageError>;
async fn blob_fetch(&self, blob_ref: &BlobRef) -> Result<BlobVal, StorageError>;
async fn blob_insert(&self, blob_val: BlobVal) -> Result<(), StorageError>;
async fn blob_copy(&self, src: &BlobRef, dst: &BlobRef) -> Result<(), StorageError>;
async fn blob_list(&self, prefix: &str) -> Result<Vec<BlobRef>, StorageError>;
async fn blob_rm(&self, blob_ref: &BlobRef) -> Result<(), StorageError>;
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct UnicityBuffer(Vec<u8>);
#[async_trait]
pub trait IBuilder: std::fmt::Debug {
async fn build(&self) -> Result<Store, StorageError>;
/// Returns an opaque buffer that uniquely identifies this builder
fn unique(&self) -> UnicityBuffer;
}
pub type Builder = Arc<dyn IBuilder + Send + Sync>;
pub type Store = Box<dyn IStore + Send + Sync>;

View file

@ -1,9 +0,0 @@
use std::time::{SystemTime, UNIX_EPOCH};
/// Returns milliseconds since UNIX Epoch
pub fn now_msec() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Fix your clock :o")
.as_millis() as u64
}

65
src/timestamp.rs Normal file
View file

@ -0,0 +1,65 @@
use rand::prelude::*;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
/// Returns milliseconds since UNIX Epoch
pub fn now_msec() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Fix your clock :o")
.as_millis() as u64
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub struct Timestamp {
pub msec: u64,
pub rand: u64,
}
impl Timestamp {
#[allow(dead_code)]
// 2023-05-15 try to make clippy happy and not sure if this fn will be used in the future.
pub fn now() -> Self {
let mut rng = thread_rng();
Self {
msec: now_msec(),
rand: rng.gen::<u64>(),
}
}
pub fn after(other: &Self) -> Self {
let mut rng = thread_rng();
Self {
msec: std::cmp::max(now_msec(), other.msec + 1),
rand: rng.gen::<u64>(),
}
}
pub fn zero() -> Self {
Self { msec: 0, rand: 0 }
}
}
impl ToString for Timestamp {
fn to_string(&self) -> String {
let mut bytes = [0u8; 16];
bytes[0..8].copy_from_slice(&u64::to_be_bytes(self.msec));
bytes[8..16].copy_from_slice(&u64::to_be_bytes(self.rand));
hex::encode(bytes)
}
}
impl FromStr for Timestamp {
type Err = &'static str;
fn from_str(s: &str) -> Result<Timestamp, &'static str> {
let bytes = hex::decode(s).map_err(|_| "invalid hex")?;
if bytes.len() != 16 {
return Err("bad length");
}
Ok(Self {
msec: u64::from_be_bytes(bytes[0..8].try_into().unwrap()),
rand: u64::from_be_bytes(bytes[8..16].try_into().unwrap()),
})
}
}

357
tests/behavior.rs Normal file
View file

@ -0,0 +1,357 @@
use anyhow::Context;
mod common;
use crate::common::constants::*;
use crate::common::fragments::*;
fn main() {
rfc3501_imap4rev1_base();
rfc6851_imapext_move();
rfc4551_imapext_condstore();
rfc2177_imapext_idle();
rfc5161_imapext_enable(); // 1
rfc3691_imapext_unselect(); // 2
rfc7888_imapext_literal(); // 3
rfc4315_imapext_uidplus(); // 4
rfc5819_imapext_liststatus(); // 5
println!("✅ SUCCESS 🌟🚀🥳🙏🥹");
}
fn rfc3501_imap4rev1_base() {
println!("🧪 rfc3501_imap4rev1_base");
common::aerogramme_provider_daemon_dev(|imap_socket, lmtp_socket| {
connect(imap_socket).context("server says hello")?;
capability(imap_socket, Extension::None).context("check server capabilities")?;
login(imap_socket, Account::Alice).context("login test")?;
create_mailbox(imap_socket, Mailbox::Archive).context("created mailbox archive")?;
let select_res =
select(imap_socket, Mailbox::Inbox, SelectMod::None).context("select inbox")?;
assert!(select_res.contains("* 0 EXISTS"));
check(imap_socket).context("check must run")?;
status(imap_socket, Mailbox::Archive, StatusKind::UidNext)
.context("status of archive from inbox")?;
lmtp_handshake(lmtp_socket).context("handshake lmtp done")?;
lmtp_deliver_email(lmtp_socket, Email::Multipart).context("mail delivered successfully")?;
noop_exists(imap_socket, 1).context("noop loop must detect a new email")?;
let srv_msg = fetch(
imap_socket,
Selection::FirstId,
FetchKind::Rfc822,
FetchMod::None,
)
.context("fetch rfc822 message, should be our first message")?;
let orig_email = std::str::from_utf8(EMAIL1)?;
assert!(srv_msg.contains(orig_email));
copy(imap_socket, Selection::FirstId, Mailbox::Archive)
.context("copy message to the archive mailbox")?;
append(imap_socket, Email::Basic).context("insert email in INBOX")?;
noop_exists(imap_socket, 2).context("noop loop must detect a new email")?;
search(imap_socket, SearchKind::Text("OoOoO")).expect("search should return something");
store(
imap_socket,
Selection::FirstId,
Flag::Deleted,
StoreAction::AddFlags,
StoreMod::None,
)
.context("should add delete flag to the email")?;
expunge(imap_socket).context("expunge emails")?;
rename_mailbox(imap_socket, Mailbox::Archive, Mailbox::Drafts)
.context("Archive mailbox is renamed Drafts")?;
delete_mailbox(imap_socket, Mailbox::Drafts).context("Drafts mailbox is deleted")?;
Ok(())
})
.expect("test fully run");
}
fn rfc3691_imapext_unselect() {
println!("🧪 rfc3691_imapext_unselect");
common::aerogramme_provider_daemon_dev(|imap_socket, lmtp_socket| {
connect(imap_socket).context("server says hello")?;
lmtp_handshake(lmtp_socket).context("handshake lmtp done")?;
lmtp_deliver_email(lmtp_socket, Email::Basic).context("mail delivered successfully")?;
capability(imap_socket, Extension::Unselect).context("check server capabilities")?;
login(imap_socket, Account::Alice).context("login test")?;
let select_res =
select(imap_socket, Mailbox::Inbox, SelectMod::None).context("select inbox")?;
assert!(select_res.contains("* 0 EXISTS"));
noop_exists(imap_socket, 1).context("noop loop must detect a new email")?;
store(
imap_socket,
Selection::FirstId,
Flag::Deleted,
StoreAction::AddFlags,
StoreMod::None,
)
.context("add delete flags to the email")?;
unselect(imap_socket)
.context("unselect inbox while preserving email with the \\Delete flag")?;
let select_res =
select(imap_socket, Mailbox::Inbox, SelectMod::None).context("select inbox again")?;
assert!(select_res.contains("* 1 EXISTS"));
let srv_msg = fetch(
imap_socket,
Selection::FirstId,
FetchKind::Rfc822,
FetchMod::None,
)
.context("message is still present")?;
let orig_email = std::str::from_utf8(EMAIL2)?;
assert!(srv_msg.contains(orig_email));
close(imap_socket).context("close inbox and expunge message")?;
let select_res = select(imap_socket, Mailbox::Inbox, SelectMod::None)
.context("select inbox again and check it's empty")?;
assert!(select_res.contains("* 0 EXISTS"));
Ok(())
})
.expect("test fully run");
}
fn rfc5161_imapext_enable() {
println!("🧪 rfc5161_imapext_enable");
common::aerogramme_provider_daemon_dev(|imap_socket, _lmtp_socket| {
connect(imap_socket).context("server says hello")?;
login(imap_socket, Account::Alice).context("login test")?;
enable(imap_socket, Enable::Utf8Accept, Some(Enable::Utf8Accept))?;
enable(imap_socket, Enable::Utf8Accept, None)?;
logout(imap_socket)?;
Ok(())
})
.expect("test fully run");
}
fn rfc6851_imapext_move() {
println!("🧪 rfc6851_imapext_move");
common::aerogramme_provider_daemon_dev(|imap_socket, lmtp_socket| {
connect(imap_socket).context("server says hello")?;
capability(imap_socket, Extension::Move).context("check server capabilities")?;
login(imap_socket, Account::Alice).context("login test")?;
create_mailbox(imap_socket, Mailbox::Archive).context("created mailbox archive")?;
let select_res =
select(imap_socket, Mailbox::Inbox, SelectMod::None).context("select inbox")?;
assert!(select_res.contains("* 0 EXISTS"));
lmtp_handshake(lmtp_socket).context("handshake lmtp done")?;
lmtp_deliver_email(lmtp_socket, Email::Basic).context("mail delivered successfully")?;
noop_exists(imap_socket, 1).context("noop loop must detect a new email")?;
r#move(imap_socket, Selection::FirstId, Mailbox::Archive)
.context("message from inbox moved to archive")?;
unselect(imap_socket)
.context("unselect inbox while preserving email with the \\Delete flag")?;
let select_res =
select(imap_socket, Mailbox::Archive, SelectMod::None).context("select archive")?;
assert!(select_res.contains("* 1 EXISTS"));
let srv_msg = fetch(
imap_socket,
Selection::FirstId,
FetchKind::Rfc822,
FetchMod::None,
)
.context("check mail exists")?;
let orig_email = std::str::from_utf8(EMAIL2)?;
assert!(srv_msg.contains(orig_email));
logout(imap_socket).context("must quit")?;
Ok(())
})
.expect("test fully run");
}
fn rfc7888_imapext_literal() {
println!("🧪 rfc7888_imapext_literal");
common::aerogramme_provider_daemon_dev(|imap_socket, _lmtp_socket| {
connect(imap_socket).context("server says hello")?;
capability(imap_socket, Extension::LiteralPlus).context("check server capabilities")?;
login_with_literal(imap_socket, Account::Alice).context("use literal to connect Alice")?;
Ok(())
})
.expect("test fully run");
}
fn rfc4551_imapext_condstore() {
println!("🧪 rfc4551_imapext_condstore");
common::aerogramme_provider_daemon_dev(|imap_socket, lmtp_socket| {
// Setup the test
connect(imap_socket).context("server says hello")?;
// RFC 3.1.1 Advertising Support for CONDSTORE
capability(imap_socket, Extension::Condstore).context("check server capabilities")?;
login(imap_socket, Account::Alice).context("login test")?;
// RFC 3.1.8. CONDSTORE Parameter to SELECT and EXAMINE
let select_res =
select(imap_socket, Mailbox::Inbox, SelectMod::Condstore).context("select inbox")?;
// RFC 3.1.2 New OK Untagged Responses for SELECT and EXAMINE
assert!(select_res.contains("[HIGHESTMODSEQ 1]"));
// RFC 3.1.3. STORE and UID STORE Commands
lmtp_handshake(lmtp_socket).context("handshake lmtp done")?;
lmtp_deliver_email(lmtp_socket, Email::Basic).context("mail delivered successfully")?;
lmtp_deliver_email(lmtp_socket, Email::Multipart).context("mail delivered successfully")?;
noop_exists(imap_socket, 2).context("noop loop must detect a new email")?;
let store_res = store(
imap_socket,
Selection::All,
Flag::Important,
StoreAction::AddFlags,
StoreMod::UnchangedSince(1),
)?;
assert!(store_res.contains("[MODIFIED 2]"));
assert!(store_res.contains("* 1 FETCH (FLAGS (\\Important) MODSEQ (3))"));
assert!(!store_res.contains("* 2 FETCH"));
assert_eq!(store_res.lines().count(), 2);
// RFC 3.1.4. FETCH and UID FETCH Commands
let fetch_res = fetch(
imap_socket,
Selection::All,
FetchKind::Rfc822Size,
FetchMod::ChangedSince(2),
)?;
assert!(fetch_res.contains("* 1 FETCH (RFC822.SIZE 81 MODSEQ (3))"));
assert!(!fetch_res.contains("* 2 FETCH"));
assert_eq!(store_res.lines().count(), 2);
// RFC 3.1.5. MODSEQ Search Criterion in SEARCH
let search_res = search(imap_socket, SearchKind::ModSeq(3))?;
// RFC 3.1.6. Modified SEARCH Untagged Response
assert!(search_res.contains("* SEARCH 1 (MODSEQ 3)"));
// RFC 3.1.7 HIGHESTMODSEQ Status Data Items
let status_res = status(imap_socket, Mailbox::Inbox, StatusKind::HighestModSeq)?;
assert!(status_res.contains("HIGHESTMODSEQ 3"));
Ok(())
})
.expect("test fully run");
}
fn rfc2177_imapext_idle() {
println!("🧪 rfc2177_imapext_idle");
common::aerogramme_provider_daemon_dev(|imap_socket, lmtp_socket| {
// Test setup, check capability
connect(imap_socket).context("server says hello")?;
capability(imap_socket, Extension::Idle).context("check server capabilities")?;
login(imap_socket, Account::Alice).context("login test")?;
select(imap_socket, Mailbox::Inbox, SelectMod::None).context("select inbox")?;
// Check that new messages from LMTP are correctly detected during idling
start_idle(imap_socket).context("can't start idling")?;
lmtp_handshake(lmtp_socket).context("handshake lmtp done")?;
lmtp_deliver_email(lmtp_socket, Email::Basic).context("mail delivered successfully")?;
let srv_msg = stop_idle(imap_socket).context("stop idling")?;
assert!(srv_msg.contains("* 1 EXISTS"));
Ok(())
})
.expect("test fully run");
}
fn rfc4315_imapext_uidplus() {
println!("🧪 rfc4315_imapext_uidplus");
common::aerogramme_provider_daemon_dev(|imap_socket, lmtp_socket| {
// Test setup, check capability, insert 2 emails
connect(imap_socket).context("server says hello")?;
capability(imap_socket, Extension::UidPlus).context("check server capabilities")?;
login(imap_socket, Account::Alice).context("login test")?;
select(imap_socket, Mailbox::Inbox, SelectMod::None).context("select inbox")?;
lmtp_handshake(lmtp_socket).context("handshake lmtp done")?;
lmtp_deliver_email(lmtp_socket, Email::Basic).context("mail delivered successfully")?;
lmtp_deliver_email(lmtp_socket, Email::Multipart).context("mail delivered successfully")?;
noop_exists(imap_socket, 2).context("noop loop must detect a new email")?;
// Check UID EXPUNGE seqset
store(
imap_socket,
Selection::All,
Flag::Deleted,
StoreAction::AddFlags,
StoreMod::None,
)?;
let res = uid_expunge(imap_socket, Selection::FirstId)?;
assert_eq!(res.lines().count(), 2);
assert!(res.contains("* 1 EXPUNGE"));
// APPENDUID check UID + UID VALIDITY
// Note: 4 and not 3, as we update the UID counter when we delete an email
// it's part of our UID proof
let res = append(imap_socket, Email::Multipart)?;
assert!(res.contains("[APPENDUID 1 4]"));
// COPYUID, check
create_mailbox(imap_socket, Mailbox::Archive).context("created mailbox archive")?;
let res = copy(imap_socket, Selection::FirstId, Mailbox::Archive)?;
assert!(res.contains("[COPYUID 1 2 1]"));
// MOVEUID, check
let res = r#move(imap_socket, Selection::FirstId, Mailbox::Archive)?;
assert!(res.contains("[COPYUID 1 2 2]"));
Ok(())
})
.expect("test fully run");
}
///
/// Example
///
/// ```text
/// 30 list "" "*" RETURN (STATUS (MESSAGES UNSEEN))
/// * LIST (\Subscribed) "." INBOX
/// * STATUS INBOX (MESSAGES 2 UNSEEN 1)
/// 30 OK LIST completed
/// ```
fn rfc5819_imapext_liststatus() {
println!("🧪 rfc5819_imapext_liststatus");
common::aerogramme_provider_daemon_dev(|imap_socket, lmtp_socket| {
// Test setup, check capability, add 2 emails, read 1
connect(imap_socket).context("server says hello")?;
capability(imap_socket, Extension::ListStatus).context("check server capabilities")?;
login(imap_socket, Account::Alice).context("login test")?;
select(imap_socket, Mailbox::Inbox, SelectMod::None).context("select inbox")?;
lmtp_handshake(lmtp_socket).context("handshake lmtp done")?;
lmtp_deliver_email(lmtp_socket, Email::Basic).context("mail delivered successfully")?;
lmtp_deliver_email(lmtp_socket, Email::Multipart).context("mail delivered successfully")?;
noop_exists(imap_socket, 2).context("noop loop must detect a new email")?;
fetch(
imap_socket,
Selection::FirstId,
FetchKind::Rfc822,
FetchMod::None,
)
.context("read one message")?;
close(imap_socket).context("close inbox")?;
// Test return status MESSAGES UNSEEN
let ret = list(
imap_socket,
MbxSelect::All,
ListReturn::StatusMessagesUnseen,
)?;
assert!(ret.contains("* STATUS INBOX (MESSAGES 2 UNSEEN 1)"));
// Test that without RETURN, no status is sent
let ret = list(imap_socket, MbxSelect::All, ListReturn::None)?;
assert!(!ret.contains("* STATUS"));
Ok(())
})
.expect("test fully run");
}

54
tests/common/constants.rs Normal file
View file

@ -0,0 +1,54 @@
use std::time;
pub static SMALL_DELAY: time::Duration = time::Duration::from_millis(200);
pub static EMAIL1: &[u8] = b"Date: Sat, 8 Jul 2023 07:14:29 +0200\r
From: Bob Robert <bob@example.tld>\r
To: Alice Malice <alice@example.tld>\r
CC: =?ISO-8859-1?Q?Andr=E9?= Pirard <PIRARD@vm1.ulg.ac.be>\r
Subject: =?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?=\r
=?ISO-8859-2?B?dSB1bmRlcnN0YW5kIHRoZSBleGFtcGxlLg==?=\r
X-Unknown: something something\r
Bad entry\r
on multiple lines\r
Message-ID: <NTAxNzA2AC47634Y366BAMTY4ODc5MzQyODY0ODY5@www.grrrndzero.org>\r
MIME-Version: 1.0\r
Content-Type: multipart/alternative;\r
boundary=\"b1_e376dc71bafc953c0b0fdeb9983a9956\"\r
Content-Transfer-Encoding: 7bit\r
\r
This is a multi-part message in MIME format.\r
\r
--b1_e376dc71bafc953c0b0fdeb9983a9956\r
Content-Type: text/plain; charset=utf-8\r
Content-Transfer-Encoding: quoted-printable\r
\r
GZ\r
OoOoO\r
oOoOoOoOo\r
oOoOoOoOoOoOoOoOo\r
oOoOoOoOoOoOoOoOoOoOoOo\r
oOoOoOoOoOoOoOoOoOoOoOoOoOoOo\r
OoOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoO\r
\r
--b1_e376dc71bafc953c0b0fdeb9983a9956\r
Content-Type: text/html; charset=us-ascii\r
\r
<div style=\"text-align: center;\"><strong>GZ</strong><br />\r
OoOoO<br />\r
oOoOoOoOo<br />\r
oOoOoOoOoOoOoOoOo<br />\r
oOoOoOoOoOoOoOoOoOoOoOo<br />\r
oOoOoOoOoOoOoOoOoOoOoOoOoOoOo<br />\r
OoOoOoOoOoOoOoOoOoOoOoOoOoOoOoOoO<br />\r
</div>\r
\r
--b1_e376dc71bafc953c0b0fdeb9983a9956--\r
";
pub static EMAIL2: &[u8] = b"From: alice@example.com\r
To: alice@example.tld\r
Subject: Test\r
\r
Hello world!\r
";

570
tests/common/fragments.rs Normal file
View file

@ -0,0 +1,570 @@
use anyhow::{bail, Result};
use std::io::Write;
use std::net::TcpStream;
use std::thread;
use crate::common::constants::*;
use crate::common::*;
/// These fragments are not a generic IMAP client
/// but specialized to our specific tests. They can't take
/// arbitrary values, only enum for which the code is known
/// to be correct. The idea is that the generated message is more
/// or less hardcoded by the developer, so its clear what's expected,
/// and not generated by a library. Also don't use vector of enum,
/// as it again introduce some kind of genericity we try so hard to avoid:
/// instead add a dedicated enum, for example "All" or anything relaevent that would
/// describe your list and then hardcode it in your fragment.
/// DON'T. TRY. TO. BE. GENERIC. HERE.
pub fn connect(imap: &mut TcpStream) -> Result<()> {
let mut buffer: [u8; 1500] = [0; 1500];
let read = read_lines(imap, &mut buffer, None)?;
assert_eq!(&read[..4], &b"* OK"[..]);
Ok(())
}
pub enum Account {
Alice,
}
pub enum Extension {
None,
Unselect,
Move,
Condstore,
LiteralPlus,
Idle,
UidPlus,
ListStatus,
}
pub enum Enable {
Utf8Accept,
CondStore,
All,
}
pub enum Mailbox {
Inbox,
Archive,
Drafts,
}
pub enum Flag {
Deleted,
Important,
}
pub enum Email {
Basic,
Multipart,
}
pub enum Selection {
FirstId,
SecondId,
All,
}
pub enum SelectMod {
None,
Condstore,
}
pub enum StoreAction {
AddFlags,
DelFlags,
SetFlags,
AddFlagsSilent,
DelFlagsSilent,
SetFlagsSilent,
}
pub enum StoreMod {
None,
UnchangedSince(u64),
}
pub enum FetchKind {
Rfc822,
Rfc822Size,
}
pub enum FetchMod {
None,
ChangedSince(u64),
}
pub enum SearchKind<'a> {
Text(&'a str),
ModSeq(u64),
}
pub enum StatusKind {
UidNext,
HighestModSeq,
}
pub enum MbxSelect {
All,
}
pub enum ListReturn {
None,
StatusMessagesUnseen,
}
pub fn capability(imap: &mut TcpStream, ext: Extension) -> Result<()> {
imap.write(&b"5 capability\r\n"[..])?;
let maybe_ext = match ext {
Extension::None => None,
Extension::Unselect => Some("UNSELECT"),
Extension::Move => Some("MOVE"),
Extension::Condstore => Some("CONDSTORE"),
Extension::LiteralPlus => Some("LITERAL+"),
Extension::Idle => Some("IDLE"),
Extension::UidPlus => Some("UIDPLUS"),
Extension::ListStatus => Some("LIST-STATUS"),
};
let mut buffer: [u8; 6000] = [0; 6000];
let read = read_lines(imap, &mut buffer, Some(&b"5 OK"[..]))?;
let srv_msg = std::str::from_utf8(read)?;
assert!(srv_msg.contains("IMAP4REV1"));
if let Some(ext) = maybe_ext {
assert!(srv_msg.contains(ext));
}
Ok(())
}
pub fn login(imap: &mut TcpStream, account: Account) -> Result<()> {
let mut buffer: [u8; 1500] = [0; 1500];
assert!(matches!(account, Account::Alice));
imap.write(&b"10 login alice hunter2\r\n"[..])?;
let read = read_lines(imap, &mut buffer, None)?;
assert_eq!(&read[..5], &b"10 OK"[..]);
Ok(())
}
pub fn login_with_literal(imap: &mut TcpStream, account: Account) -> Result<()> {
let mut buffer: [u8; 1500] = [0; 1500];
assert!(matches!(account, Account::Alice));
imap.write(&b"10 login {5+}\r\nalice {7+}\r\nhunter2\r\n"[..])?;
let _read = read_lines(imap, &mut buffer, Some(&b"10 OK"[..]))?;
Ok(())
}
pub fn create_mailbox(imap: &mut TcpStream, mbx: Mailbox) -> Result<()> {
let mut buffer: [u8; 1500] = [0; 1500];
let mbx_str = match mbx {
Mailbox::Inbox => "INBOX",
Mailbox::Archive => "ArchiveCustom",
Mailbox::Drafts => "DraftsCustom",
};
let cmd = format!("15 create {}\r\n", mbx_str);
imap.write(cmd.as_bytes())?;
let read = read_lines(imap, &mut buffer, None)?;
assert_eq!(&read[..12], &b"15 OK CREATE"[..]);
Ok(())
}
pub fn list(imap: &mut TcpStream, select: MbxSelect, mod_return: ListReturn) -> Result<String> {
let mut buffer: [u8; 6000] = [0; 6000];
let select_str = match select {
MbxSelect::All => "%",
};
let mod_return_str = match mod_return {
ListReturn::None => "",
ListReturn::StatusMessagesUnseen => " RETURN (STATUS (MESSAGES UNSEEN))",
};
imap.write(format!("19 LIST \"\" \"{}\"{}\r\n", select_str, mod_return_str).as_bytes())?;
let read = read_lines(imap, &mut buffer, Some(&b"19 OK"[..]))?;
let srv_msg = std::str::from_utf8(read)?;
Ok(srv_msg.to_string())
}
pub fn select(imap: &mut TcpStream, mbx: Mailbox, modifier: SelectMod) -> Result<String> {
let mut buffer: [u8; 6000] = [0; 6000];
let mbx_str = match mbx {
Mailbox::Inbox => "INBOX",
Mailbox::Archive => "ArchiveCustom",
Mailbox::Drafts => "DraftsCustom",
};
let mod_str = match modifier {
SelectMod::Condstore => " (CONDSTORE)",
SelectMod::None => "",
};
imap.write(format!("20 select {}{}\r\n", mbx_str, mod_str).as_bytes())?;
let read = read_lines(imap, &mut buffer, Some(&b"20 OK"[..]))?;
let srv_msg = std::str::from_utf8(read)?;
Ok(srv_msg.to_string())
}
pub fn unselect(imap: &mut TcpStream) -> Result<()> {
imap.write(&b"70 unselect\r\n"[..])?;
let mut buffer: [u8; 1500] = [0; 1500];
let _read = read_lines(imap, &mut buffer, Some(&b"70 OK"[..]))?;
Ok(())
}
pub fn check(imap: &mut TcpStream) -> Result<()> {
let mut buffer: [u8; 1500] = [0; 1500];
imap.write(&b"21 check\r\n"[..])?;
let _read = read_lines(imap, &mut buffer, Some(&b"21 OK"[..]))?;
Ok(())
}
pub fn status(imap: &mut TcpStream, mbx: Mailbox, sk: StatusKind) -> Result<String> {
let mbx_str = match mbx {
Mailbox::Inbox => "INBOX",
Mailbox::Archive => "ArchiveCustom",
Mailbox::Drafts => "DraftsCustom",
};
let sk_str = match sk {
StatusKind::UidNext => "(UIDNEXT)",
StatusKind::HighestModSeq => "(HIGHESTMODSEQ)",
};
imap.write(format!("25 STATUS {} {}\r\n", mbx_str, sk_str).as_bytes())?;
let mut buffer: [u8; 6000] = [0; 6000];
let read = read_lines(imap, &mut buffer, Some(&b"25 OK"[..]))?;
let srv_msg = std::str::from_utf8(read)?;
Ok(srv_msg.to_string())
}
pub fn lmtp_handshake(lmtp: &mut TcpStream) -> Result<()> {
let mut buffer: [u8; 1500] = [0; 1500];
let _read = read_lines(lmtp, &mut buffer, None)?;
assert_eq!(&buffer[..4], &b"220 "[..]);
lmtp.write(&b"LHLO example.tld\r\n"[..])?;
let _read = read_lines(lmtp, &mut buffer, Some(&b"250 "[..]))?;
Ok(())
}
pub fn lmtp_deliver_email(lmtp: &mut TcpStream, email_type: Email) -> Result<()> {
let mut buffer: [u8; 1500] = [0; 1500];
let email = match email_type {
Email::Basic => EMAIL2,
Email::Multipart => EMAIL1,
};
lmtp.write(&b"MAIL FROM:<bob@example.tld>\r\n"[..])?;
let _read = read_lines(lmtp, &mut buffer, Some(&b"250 2.0.0"[..]))?;
lmtp.write(&b"RCPT TO:<alice@example.tld>\r\n"[..])?;
let _read = read_lines(lmtp, &mut buffer, Some(&b"250 2.1.5"[..]))?;
lmtp.write(&b"DATA\r\n"[..])?;
let _read = read_lines(lmtp, &mut buffer, Some(&b"354 "[..]))?;
lmtp.write(email)?;
lmtp.write(&b"\r\n.\r\n"[..])?;
let _read = read_lines(lmtp, &mut buffer, Some(&b"250 2.0.0"[..]))?;
Ok(())
}
pub fn noop_exists(imap: &mut TcpStream, must_exists: u32) -> Result<()> {
let mut buffer: [u8; 6000] = [0; 6000];
let mut max_retry = 20;
loop {
max_retry -= 1;
imap.write(&b"30 NOOP\r\n"[..])?;
let read = read_lines(imap, &mut buffer, Some(&b"30 OK"[..]))?;
let srv_msg = std::str::from_utf8(read)?;
for line in srv_msg.lines() {
if line.contains("EXISTS") {
let got = read_first_u32(line)?;
if got == must_exists {
// Done
return Ok(());
}
}
}
if max_retry <= 0 {
// Failed
bail!("no more retry");
}
thread::sleep(SMALL_DELAY);
}
}
pub fn fetch(
imap: &mut TcpStream,
selection: Selection,
kind: FetchKind,
modifier: FetchMod,
) -> Result<String> {
let mut buffer: [u8; 65535] = [0; 65535];
let sel_str = match selection {
Selection::FirstId => "1",
Selection::SecondId => "2",
Selection::All => "1:*",
};
let kind_str = match kind {
FetchKind::Rfc822 => "RFC822",
FetchKind::Rfc822Size => "RFC822.SIZE",
};
let mod_str = match modifier {
FetchMod::None => "".into(),
FetchMod::ChangedSince(val) => format!(" (CHANGEDSINCE {})", val),
};
imap.write(format!("40 fetch {} {}{}\r\n", sel_str, kind_str, mod_str).as_bytes())?;
let read = read_lines(imap, &mut buffer, Some(&b"40 OK FETCH"[..]))?;
let srv_msg = std::str::from_utf8(read)?;
Ok(srv_msg.to_string())
}
pub fn copy(imap: &mut TcpStream, selection: Selection, to: Mailbox) -> Result<String> {
let mut buffer: [u8; 65535] = [0; 65535];
assert!(matches!(selection, Selection::FirstId));
assert!(matches!(to, Mailbox::Archive));
imap.write(&b"45 copy 1 ArchiveCustom\r\n"[..])?;
let read = read_lines(imap, &mut buffer, None)?;
assert_eq!(&read[..5], &b"45 OK"[..]);
let srv_msg = std::str::from_utf8(read)?;
Ok(srv_msg.to_string())
}
pub fn append(imap: &mut TcpStream, content: Email) -> Result<String> {
let mut buffer: [u8; 6000] = [0; 6000];
let ref_mail = match content {
Email::Multipart => EMAIL1,
Email::Basic => EMAIL2,
};
let append_cmd = format!("47 append inbox (\\Seen) {{{}}}\r\n", ref_mail.len());
println!("append cmd: {}", append_cmd);
imap.write(append_cmd.as_bytes())?;
// wait for continuation
let read = read_lines(imap, &mut buffer, None)?;
assert_eq!(read[0], b'+');
// write our stuff
imap.write(ref_mail)?;
imap.write(&b"\r\n"[..])?;
let read = read_lines(imap, &mut buffer, Some(&b"47 OK"[..]))?;
let srv_msg = std::str::from_utf8(read)?;
Ok(srv_msg.to_string())
}
pub fn search(imap: &mut TcpStream, sk: SearchKind) -> Result<String> {
let sk_str = match sk {
SearchKind::Text(x) => format!("TEXT \"{}\"", x),
SearchKind::ModSeq(x) => format!("MODSEQ {}", x),
};
imap.write(format!("55 SEARCH {}\r\n", sk_str).as_bytes())?;
let mut buffer: [u8; 1500] = [0; 1500];
let read = read_lines(imap, &mut buffer, Some(&b"55 OK"[..]))?;
let srv_msg = std::str::from_utf8(read)?;
Ok(srv_msg.to_string())
}
pub fn store(
imap: &mut TcpStream,
sel: Selection,
flag: Flag,
action: StoreAction,
modifier: StoreMod,
) -> Result<String> {
let mut buffer: [u8; 6000] = [0; 6000];
let seq = match sel {
Selection::FirstId => "1",
Selection::SecondId => "2",
Selection::All => "1:*",
};
let modif = match modifier {
StoreMod::None => "".into(),
StoreMod::UnchangedSince(val) => format!(" (UNCHANGEDSINCE {})", val),
};
let flags_str = match flag {
Flag::Deleted => "(\\Deleted)",
Flag::Important => "(\\Important)",
};
let action_str = match action {
StoreAction::AddFlags => "+FLAGS",
StoreAction::DelFlags => "-FLAGS",
StoreAction::SetFlags => "FLAGS",
StoreAction::AddFlagsSilent => "+FLAGS.SILENT",
StoreAction::DelFlagsSilent => "-FLAGS.SILENT",
StoreAction::SetFlagsSilent => "FLAGS.SILENT",
};
imap.write(format!("57 STORE {}{} {} {}\r\n", seq, modif, action_str, flags_str).as_bytes())?;
let read = read_lines(imap, &mut buffer, Some(&b"57 OK"[..]))?;
let srv_msg = std::str::from_utf8(read)?;
Ok(srv_msg.to_string())
}
pub fn expunge(imap: &mut TcpStream) -> Result<()> {
imap.write(&b"60 expunge\r\n"[..])?;
let mut buffer: [u8; 1500] = [0; 1500];
let _read = read_lines(imap, &mut buffer, Some(&b"60 OK EXPUNGE"[..]))?;
Ok(())
}
pub fn uid_expunge(imap: &mut TcpStream, sel: Selection) -> Result<String> {
use Selection::*;
let mut buffer: [u8; 6000] = [0; 6000];
let selstr = match sel {
FirstId => "1",
SecondId => "2",
All => "1:*",
};
imap.write(format!("61 UID EXPUNGE {}\r\n", selstr).as_bytes())?;
let read = read_lines(imap, &mut buffer, Some(&b"61 OK"[..]))?;
let srv_msg = std::str::from_utf8(read)?;
Ok(srv_msg.to_string())
}
pub fn rename_mailbox(imap: &mut TcpStream, from: Mailbox, to: Mailbox) -> Result<()> {
assert!(matches!(from, Mailbox::Archive));
assert!(matches!(to, Mailbox::Drafts));
imap.write(&b"70 rename ArchiveCustom DraftsCustom\r\n"[..])?;
let mut buffer: [u8; 1500] = [0; 1500];
let read = read_lines(imap, &mut buffer, None)?;
assert_eq!(&read[..5], &b"70 OK"[..]);
imap.write(&b"71 list \"\" *\r\n"[..])?;
let read = read_lines(imap, &mut buffer, Some(&b"71 OK LIST"[..]))?;
let srv_msg = std::str::from_utf8(read)?;
assert!(!srv_msg.contains(" ArchiveCustom\r\n"));
assert!(srv_msg.contains(" INBOX\r\n"));
assert!(srv_msg.contains(" DraftsCustom\r\n"));
Ok(())
}
pub fn delete_mailbox(imap: &mut TcpStream, mbx: Mailbox) -> Result<()> {
let mbx_str = match mbx {
Mailbox::Inbox => "INBOX",
Mailbox::Archive => "ArchiveCustom",
Mailbox::Drafts => "DraftsCustom",
};
let cmd = format!("80 delete {}\r\n", mbx_str);
imap.write(cmd.as_bytes())?;
let mut buffer: [u8; 1500] = [0; 1500];
let read = read_lines(imap, &mut buffer, None)?;
assert_eq!(&read[..5], &b"80 OK"[..]);
imap.write(&b"81 list \"\" *\r\n"[..])?;
let read = read_lines(imap, &mut buffer, Some(&b"81 OK"[..]))?;
let srv_msg = std::str::from_utf8(read)?;
assert!(srv_msg.contains(" INBOX\r\n"));
assert!(!srv_msg.contains(format!(" {}\r\n", mbx_str).as_str()));
Ok(())
}
pub fn close(imap: &mut TcpStream) -> Result<()> {
imap.write(&b"60 close\r\n"[..])?;
let mut buffer: [u8; 1500] = [0; 1500];
let _read = read_lines(imap, &mut buffer, Some(&b"60 OK"[..]))?;
Ok(())
}
pub fn r#move(imap: &mut TcpStream, selection: Selection, to: Mailbox) -> Result<String> {
let mut buffer: [u8; 1500] = [0; 1500];
assert!(matches!(to, Mailbox::Archive));
assert!(matches!(selection, Selection::FirstId));
imap.write(&b"35 move 1 ArchiveCustom\r\n"[..])?;
let read = read_lines(imap, &mut buffer, Some(&b"35 OK"[..]))?;
let srv_msg = std::str::from_utf8(read)?;
assert!(srv_msg.contains("* 1 EXPUNGE"));
Ok(srv_msg.to_string())
}
pub fn enable(imap: &mut TcpStream, ask: Enable, done: Option<Enable>) -> Result<()> {
let mut buffer: [u8; 6000] = [0; 6000];
assert!(matches!(ask, Enable::Utf8Accept));
imap.write(&b"36 enable UTF8=ACCEPT\r\n"[..])?;
let read = read_lines(imap, &mut buffer, Some(&b"36 OK"[..]))?;
let srv_msg = std::str::from_utf8(read)?;
match done {
None => assert_eq!(srv_msg.lines().count(), 1),
Some(Enable::Utf8Accept) => {
assert_eq!(srv_msg.lines().count(), 2);
assert!(srv_msg.contains("* ENABLED UTF8=ACCEPT"));
}
_ => unimplemented!(),
}
Ok(())
}
pub fn start_idle(imap: &mut TcpStream) -> Result<()> {
let mut buffer: [u8; 1500] = [0; 1500];
imap.write(&b"98 IDLE\r\n"[..])?;
let read = read_lines(imap, &mut buffer, None)?;
assert_eq!(read[0], b'+');
Ok(())
}
pub fn stop_idle(imap: &mut TcpStream) -> Result<String> {
let mut buffer: [u8; 16536] = [0; 16536];
imap.write(&b"DONE\r\n"[..])?;
let read = read_lines(imap, &mut buffer, Some(&b"98 OK"[..]))?;
let srv_msg = std::str::from_utf8(read)?;
Ok(srv_msg.to_string())
}
pub fn logout(imap: &mut TcpStream) -> Result<()> {
imap.write(&b"99 logout\r\n"[..])?;
let mut buffer: [u8; 1500] = [0; 1500];
let read = read_lines(imap, &mut buffer, None)?;
assert_eq!(&read[..5], &b"* BYE"[..]);
Ok(())
}

99
tests/common/mod.rs Normal file
View file

@ -0,0 +1,99 @@
#![allow(dead_code)]
pub mod constants;
pub mod fragments;
use anyhow::{bail, Context, Result};
use std::io::Read;
use std::net::{Shutdown, TcpStream};
use std::process::Command;
use std::thread;
use constants::SMALL_DELAY;
pub fn aerogramme_provider_daemon_dev(
mut fx: impl FnMut(&mut TcpStream, &mut TcpStream) -> Result<()>,
) -> Result<()> {
// Check port is not used (= free) before starting the test
let mut max_retry = 20;
loop {
max_retry -= 1;
match (TcpStream::connect("[::1]:1143"), max_retry) {
(Ok(_), 0) => bail!("something is listening on [::1]:1143 and prevent the test from starting"),
(Ok(_), _) => println!("something is listening on [::1]:1143, maybe a previous daemon quitting, retrying soon..."),
(Err(_), _) => {
println!("test ready to start, [::1]:1143 is free!");
break
}
}
thread::sleep(SMALL_DELAY);
}
// Start daemon
let mut daemon = Command::new(env!("CARGO_BIN_EXE_aerogramme"))
.arg("--dev")
.arg("provider")
.arg("daemon")
.spawn()?;
// Check that our daemon is correctly listening on the free port
let mut max_retry = 20;
let mut imap_socket = loop {
max_retry -= 1;
match (TcpStream::connect("[::1]:1143"), max_retry) {
(Err(e), 0) => bail!("no more retry, last error is: {}", e),
(Err(e), _) => {
println!("unable to connect: {} ; will retry soon...", e);
}
(Ok(v), _) => break v,
}
thread::sleep(SMALL_DELAY);
};
// Assuming now it's safe to open a LMTP socket
let mut lmtp_socket =
TcpStream::connect("[::1]:1025").context("lmtp socket must be connected")?;
println!("-- ready to test imap features --");
let result = fx(&mut imap_socket, &mut lmtp_socket);
println!("-- test teardown --");
imap_socket
.shutdown(Shutdown::Both)
.context("closing imap socket at the end of the test")?;
lmtp_socket
.shutdown(Shutdown::Both)
.context("closing lmtp socket at the end of the test")?;
daemon.kill().context("daemon should be killed")?;
result.context("all tests passed")
}
pub fn read_lines<'a, F: Read>(
reader: &mut F,
buffer: &'a mut [u8],
stop_marker: Option<&[u8]>,
) -> Result<&'a [u8]> {
let mut nbytes = 0;
loop {
nbytes += reader.read(&mut buffer[nbytes..])?;
//println!("partial read: {}", std::str::from_utf8(&buffer[..nbytes])?);
let pre_condition = match stop_marker {
None => true,
Some(mark) => buffer[..nbytes].windows(mark.len()).any(|w| w == mark),
};
if pre_condition && nbytes >= 2 && &buffer[nbytes - 2..nbytes] == &b"\r\n"[..] {
break;
}
}
println!("read: {}", std::str::from_utf8(&buffer[..nbytes])?);
Ok(&buffer[..nbytes])
}
pub fn read_first_u32(inp: &str) -> Result<u32> {
Ok(inp
.chars()
.skip_while(|c| !c.is_digit(10))
.take_while(|c| c.is_digit(10))
.collect::<String>()
.parse::<u32>()?)
}

View file

@ -1,9 +0,0 @@
#!/bin/sh
maddy -config /data/maddy.conf run &
sleep 2
maddyctl creds create --password pass test@example.com
maddyctl imap-acct create test@example.com
wait

1
tests/emails/.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
*.zstd filter=lfs diff=lfs merge=lfs -text

1
tests/emails/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.mbox

BIN
tests/emails/aero100.mbox.zstd (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,240 @@
Append { mailbox: Other(MailboxOther(Atom(AtomExt("Sent")))), flags: [Seen], date: None, message: REDACTED } }
Append { mailbox: Other(MailboxOther(String(Quoted(Quoted("Drafts"))))), flags: [Seen, Draft], date: None, message: REDACTED } }
Append { mailbox: Other(MailboxOther(String(Quoted(Quoted("Sent"))))), flags: [Seen], date: None, message: REDACTED } }
Append { mailbox: Other(MailboxOther(String(Quoted(Quoted("Sent"))))), flags: [Seen], date: Some(2024-02-14T14:12:35+01:00), message: REDACTED } }
Capability
Check
Close
Create { mailbox: Other(MailboxOther(Atom(AtomExt("Mailspring")))) } }
Create { mailbox: Other(MailboxOther(String(Quoted(Quoted("Dataset"))))) } }
Create { mailbox: Other(MailboxOther(String(Quoted(Quoted("Mailspring.Snoozed"))))) } }
Enable { capabilities: [CondStore]+ } }
Enable { capabilities: [Utf8(Accept)]+ } }
Examine { mailbox: Inbox, modifiers: [] } }
Expunge { uid_sequence_set: None } }
Fetch { sequence_set: SequenceSet([Range(Asterisk, Value(5))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Envelope, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("References"))]+)), partial: None, peek: true }]), uid: false } }
Fetch { sequence_set: SequenceSet([Range(Value(12), Value(13))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([InternalDate, Uid, Rfc822Size, Flags, ModSeq, BodyExt { section: Some(Header(None)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Asterisk)]+), modifiers: [ChangedSince(22)], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Asterisk)]+), modifiers: [ChangedSince(24)], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Asterisk)]+), modifiers: [ChangedSince(5)], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Asterisk)]+), modifiers: [ChangedSince(638435220681800000)], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Asterisk)]+), modifiers: [ChangedSince(638435229950250000)], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Asterisk)]+), modifiers: [ChangedSince(638435249520030000)], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Asterisk)]+), modifiers: [ChangedSince(6)], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Asterisk)]+), modifiers: [ChangedSince(7)], macro_or_item_names: MessageDataItemNames([Uid, Flags, Envelope, InternalDate, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("References"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Asterisk)]+), modifiers: [ChangedSince(8)], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Asterisk)]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Asterisk)]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(1))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags, InternalDate, Rfc822Size, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("DATE")), Atom(AtomExt("FROM")), Atom(AtomExt("SENDER")), Atom(AtomExt("SUBJECT")), Atom(AtomExt("TO")), Atom(AtomExt("CC")), Atom(AtomExt("MESSAGE-ID")), Atom(AtomExt("REFERENCES")), Atom(AtomExt("CONTENT-TYPE")), Atom(AtomExt("CONTENT-DESCRIPTION")), Atom(AtomExt("IN-REPLY-TO")), Atom(AtomExt("REPLY-TO")), Atom(AtomExt("LINES")), Atom(AtomExt("LIST-POST")), Atom(AtomExt("X-LABEL"))]+)), partial: None, peek: true }]), uid: false } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(20))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags, Envelope, InternalDate, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("References"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(20))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(23))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Flags, Uid]), uid: false } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([InternalDate, Uid, Flags, Rfc822Size, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("DATE")), Atom(AtomExt("FROM")), Atom(AtomExt("SUBJECT")), Atom(AtomExt("CONTENT-TYPE")), Atom(AtomExt("X-MS-TNEF-Correlator")), Atom(AtomExt("CONTENT-CLASS")), Atom(AtomExt("IMPORTANCE")), Atom(AtomExt("PRIORITY")), Atom(AtomExt("X-PRIORITY")), Atom(AtomExt("THREAD-TOPIC")), Atom(AtomExt("REPLY-TO"))]+)), partial: None, peek: true }, BodyStructure]), uid: false } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags, InternalDate, Rfc822Size, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("DATE")), Atom(AtomExt("FROM")), Atom(AtomExt("SENDER")), Atom(AtomExt("SUBJECT")), Atom(AtomExt("TO")), Atom(AtomExt("CC")), Atom(AtomExt("MESSAGE-ID")), Atom(AtomExt("REFERENCES")), Atom(AtomExt("CONTENT-TYPE")), Atom(AtomExt("CONTENT-DESCRIPTION")), Atom(AtomExt("IN-REPLY-TO")), Atom(AtomExt("REPLY-TO")), Atom(AtomExt("LINES")), Atom(AtomExt("LIST-POST")), Atom(AtomExt("X-LABEL"))]+)), partial: None, peek: true }]), uid: false } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, Flags, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("From")), Atom(AtomExt("To")), Atom(AtomExt("Cc")), Atom(AtomExt("Bcc")), Atom(AtomExt("Subject")), Atom(AtomExt("Date")), Atom(AtomExt("Message-ID")), Atom(AtomExt("Priority")), Atom(AtomExt("X-Priority")), Atom(AtomExt("References")), Atom(AtomExt("Newsgroups")), Atom(AtomExt("In-Reply-To")), Atom(AtomExt("Content-Type")), Atom(AtomExt("Reply-To"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid]), uid: false } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(2)), Range(Value(11), Value(13)), Range(Value(18), Value(19)), Range(Value(22), Value(26))]+), modifiers: [ChangedSince(68)], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(2)), Range(Value(11), Value(13)), Range(Value(18), Value(19)), Range(Value(22), Value(26))]+), modifiers: [ChangedSince(69)], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(2)), Range(Value(11), Value(13)), Range(Value(18), Value(19)), Range(Value(22), Value(26)), Range(Value(33), Value(34))]+), modifiers: [ChangedSince(102)], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(2)), Range(Value(11), Value(13)), Range(Value(18), Value(19)), Range(Value(22), Value(26)), Range(Value(33), Value(34))]+), modifiers: [ChangedSince(189)], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(2)), Range(Value(11), Value(13)), Range(Value(18), Value(19)), Range(Value(22), Value(26)), Range(Value(33), Value(34))]+), modifiers: [ChangedSince(81)], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(2)), Range(Value(11), Value(13)), Range(Value(18), Value(19)), Range(Value(22), Value(26)), Range(Value(33), Value(34)), Range(Value(60), Value(62))]+), modifiers: [ChangedSince(165)], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(2)), Range(Value(11), Value(13)), Range(Value(18), Value(19)), Range(Value(22), Value(26)), Range(Value(33), Value(34)), Range(Value(60), Value(62))]+), modifiers: [ChangedSince(166)], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(2)), Range(Value(11), Value(13)), Range(Value(18), Value(19)), Range(Value(22), Value(26)), Range(Value(33), Value(34)), Range(Value(60), Value(62))]+), modifiers: [ChangedSince(168)], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(3))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Flags, Uid]), uid: false } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(3))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: false } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(4))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([InternalDate, Uid, Flags, Rfc822Size, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("DATE")), Atom(AtomExt("FROM")), Atom(AtomExt("SUBJECT")), Atom(AtomExt("CONTENT-TYPE")), Atom(AtomExt("X-MS-TNEF-Correlator")), Atom(AtomExt("CONTENT-CLASS")), Atom(AtomExt("IMPORTANCE")), Atom(AtomExt("PRIORITY")), Atom(AtomExt("X-PRIORITY")), Atom(AtomExt("THREAD-TOPIC")), Atom(AtomExt("REPLY-TO"))]+)), partial: None, peek: true }, BodyStructure]), uid: false } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(4))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags, Envelope, InternalDate, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("References"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(4))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(6))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags, Envelope, InternalDate, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("References"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(7))]+), modifiers: [ChangedSince(9)], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(7))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: false } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(7))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(1), Value(7))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(20), Value(23))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags, Envelope, InternalDate, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("References"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(27), Asterisk)]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(2), Asterisk)]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(30), Asterisk)]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(3), Asterisk)]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(3), Asterisk)]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(4), Asterisk)]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(5), Asterisk)]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(5), Value(7))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([InternalDate, Uid, Flags, Rfc822Size, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("DATE")), Atom(AtomExt("FROM")), Atom(AtomExt("SUBJECT")), Atom(AtomExt("CONTENT-TYPE")), Atom(AtomExt("X-MS-TNEF-Correlator")), Atom(AtomExt("CONTENT-CLASS")), Atom(AtomExt("IMPORTANCE")), Atom(AtomExt("PRIORITY")), Atom(AtomExt("X-PRIORITY")), Atom(AtomExt("THREAD-TOPIC")), Atom(AtomExt("REPLY-TO"))]+)), partial: None, peek: true }, BodyStructure]), uid: false } }
Fetch { sequence_set: SequenceSet([Range(Value(5), Value(7))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, Flags, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("From")), Atom(AtomExt("To")), Atom(AtomExt("Cc")), Atom(AtomExt("Bcc")), Atom(AtomExt("Subject")), Atom(AtomExt("Date")), Atom(AtomExt("Message-ID")), Atom(AtomExt("Priority")), Atom(AtomExt("X-Priority")), Atom(AtomExt("References")), Atom(AtomExt("Newsgroups")), Atom(AtomExt("In-Reply-To")), Atom(AtomExt("Content-Type")), Atom(AtomExt("Reply-To"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(60), Value(62))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([InternalDate, Uid, Rfc822Size, Flags, ModSeq, BodyExt { section: Some(Header(None)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(60), Value(62))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(63), Asterisk)]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(6), Asterisk)]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(6), Value(7))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags, Envelope, InternalDate, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("References"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(6), Value(7))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags, InternalDate, Envelope, BodyStructure, Rfc822Size, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("REFERENCES")), Atom(AtomExt("THREAD-TOPIC")), Atom(AtomExt("FROM")), Atom(AtomExt("SENDER")), Atom(AtomExt("REPLY-TO"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(6), Value(7))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, Flags, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("From")), Atom(AtomExt("To")), Atom(AtomExt("Cc")), Atom(AtomExt("Bcc")), Atom(AtomExt("Subject")), Atom(AtomExt("Date")), Atom(AtomExt("Message-ID")), Atom(AtomExt("Priority")), Atom(AtomExt("X-Priority")), Atom(AtomExt("References")), Atom(AtomExt("Newsgroups")), Atom(AtomExt("In-Reply-To")), Atom(AtomExt("Content-Type")), Atom(AtomExt("Reply-To"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(86), Asterisk)]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(8), Asterisk)]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Range(Value(9), Asterisk)]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(13))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: Some(Header(None)), partial: None, peek: true }, BodyExt { section: Some(Text(None)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(1))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(1))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Flags, Uid]), uid: false } }
Fetch { sequence_set: SequenceSet([Single(Value(1))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Rfc822Header, BodyExt { section: Some(Part(Part([1]+))), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(1))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(1))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, BodyExt { section: None, partial: None, peek: false }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(1))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(1))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, Flags, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("From")), Atom(AtomExt("To")), Atom(AtomExt("Cc")), Atom(AtomExt("Bcc")), Atom(AtomExt("Resent-Message-ID")), Atom(AtomExt("Subject")), Atom(AtomExt("Date")), Atom(AtomExt("Message-ID")), Atom(AtomExt("Priority")), Atom(AtomExt("X-Priority")), Atom(AtomExt("References")), Atom(AtomExt("Newsgroups")), Atom(AtomExt("In-Reply-To")), Atom(AtomExt("Content-Type")), Atom(AtomExt("Reply-To")), Atom(AtomExt("List-Unsubscribe")), Atom(AtomExt("Received")), Atom(AtomExt("Delivery-Date"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(1))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(1)), Single(Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("bcc")), Atom(AtomExt("cc")), Atom(AtomExt("date")), Atom(AtomExt("from")), Atom(AtomExt("reply-to")), Atom(AtomExt("sender")), Atom(AtomExt("subject")), Atom(AtomExt("to"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(1)), Single(Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("in-reply-to")), Atom(AtomExt("message-id")), Atom(AtomExt("references"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(1)), Single(Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: Some(Text(None)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(1)), Single(Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([InternalDate, Rfc822Size, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(1)), Single(Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Rfc822Header]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(22))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(24))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, BodyExt { section: Some(Text(None)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(24))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags, InternalDate, Envelope, BodyStructure, Rfc822Size, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("REFERENCES")), Atom(AtomExt("THREAD-TOPIC")), Atom(AtomExt("FROM")), Atom(AtomExt("SENDER")), Atom(AtomExt("REPLY-TO"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(24))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(24))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, Flags, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("From")), Atom(AtomExt("To")), Atom(AtomExt("Cc")), Atom(AtomExt("Bcc")), Atom(AtomExt("Subject")), Atom(AtomExt("Date")), Atom(AtomExt("Message-ID")), Atom(AtomExt("Priority")), Atom(AtomExt("X-Priority")), Atom(AtomExt("References")), Atom(AtomExt("Newsgroups")), Atom(AtomExt("In-Reply-To")), Atom(AtomExt("Content-Type")), Atom(AtomExt("Reply-To"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(24)), Single(Value(1))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, Flags, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("From")), Atom(AtomExt("To")), Atom(AtomExt("Cc")), Atom(AtomExt("Bcc")), Atom(AtomExt("Resent-Message-ID")), Atom(AtomExt("Subject")), Atom(AtomExt("Date")), Atom(AtomExt("Message-ID")), Atom(AtomExt("Priority")), Atom(AtomExt("X-Priority")), Atom(AtomExt("References")), Atom(AtomExt("Newsgroups")), Atom(AtomExt("In-Reply-To")), Atom(AtomExt("Content-Type")), Atom(AtomExt("Reply-To")), Atom(AtomExt("List-Unsubscribe")), Atom(AtomExt("Received")), Atom(AtomExt("Delivery-Date"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(29))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(29))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, Flags, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("From")), Atom(AtomExt("To")), Atom(AtomExt("Cc")), Atom(AtomExt("Bcc")), Atom(AtomExt("Subject")), Atom(AtomExt("Date")), Atom(AtomExt("Message-ID")), Atom(AtomExt("Priority")), Atom(AtomExt("X-Priority")), Atom(AtomExt("References")), Atom(AtomExt("Newsgroups")), Atom(AtomExt("In-Reply-To")), Atom(AtomExt("Content-Type")), Atom(AtomExt("Reply-To"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: Some(Text(None)), partial: None, peek: true }]), uid: false } }
Fetch { sequence_set: SequenceSet([Single(Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Envelope, InternalDate, Rfc822Size, Flags, BodyStructure, Uid, BodyExt { section: Some(Header(None)), partial: None, peek: true }, Rfc822Size, InternalDate]), uid: false } }
Fetch { sequence_set: SequenceSet([Single(Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Rfc822Header, BodyExt { section: Some(Part(Part([1]+))), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Rfc822Header, BodyExt { section: Some(Part(Part([2]+))), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(2))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(2)), Single(Value(8))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, Flags, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("From")), Atom(AtomExt("To")), Atom(AtomExt("Cc")), Atom(AtomExt("Bcc")), Atom(AtomExt("Subject")), Atom(AtomExt("Date")), Atom(AtomExt("Message-ID")), Atom(AtomExt("Priority")), Atom(AtomExt("X-Priority")), Atom(AtomExt("References")), Atom(AtomExt("Newsgroups")), Atom(AtomExt("In-Reply-To")), Atom(AtomExt("Content-Type")), Atom(AtomExt("Reply-To"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(3))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(3))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([InternalDate, Uid, Flags, Rfc822Size, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("DATE")), Atom(AtomExt("FROM")), Atom(AtomExt("SUBJECT")), Atom(AtomExt("CONTENT-TYPE")), Atom(AtomExt("X-MS-TNEF-Correlator")), Atom(AtomExt("CONTENT-CLASS")), Atom(AtomExt("IMPORTANCE")), Atom(AtomExt("PRIORITY")), Atom(AtomExt("X-PRIORITY")), Atom(AtomExt("THREAD-TOPIC")), Atom(AtomExt("REPLY-TO"))]+)), partial: None, peek: true }, BodyStructure]), uid: false } }
Fetch { sequence_set: SequenceSet([Single(Value(3))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Rfc822Header, BodyExt { section: Some(Part(Part([1]+))), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(3))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Rfc822Header, BodyExt { section: Some(Part(Part([2]+))), partial: Some((0, 10240)), peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(3))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, BodyStructure, InternalDate, Rfc822Size]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(3))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags, InternalDate, Envelope, BodyStructure, Rfc822Size, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("REFERENCES")), Atom(AtomExt("THREAD-TOPIC")), Atom(AtomExt("FROM")), Atom(AtomExt("SENDER")), Atom(AtomExt("REPLY-TO")), Atom(AtomExt("AUTO-SUBMITTED")), Atom(AtomExt("BOUNCES-TO")), Atom(AtomExt("LIST-ARCHIVE")), Atom(AtomExt("LIST-HELP")), Atom(AtomExt("LIST-ID")), Atom(AtomExt("LIST-OWNER")), Atom(AtomExt("LIST-POST")), Atom(AtomExt("LIST-SUBSCRIBE")), Atom(AtomExt("LIST-UNSUBSCRIBE")), Atom(AtomExt("PRECEDENCE")), Atom(AtomExt("RESENT-FROM")), Atom(AtomExt("RETURN-PATH"))]+)), partial: None, peek: true }, BodyExt { section: Some(Text(None)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(3))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(3))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, Flags, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("From")), Atom(AtomExt("To")), Atom(AtomExt("Cc")), Atom(AtomExt("Bcc")), Atom(AtomExt("Subject")), Atom(AtomExt("Date")), Atom(AtomExt("Message-ID")), Atom(AtomExt("Priority")), Atom(AtomExt("X-Priority")), Atom(AtomExt("References")), Atom(AtomExt("Newsgroups")), Atom(AtomExt("In-Reply-To")), Atom(AtomExt("Content-Type")), Atom(AtomExt("Reply-To"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(3)), Single(Value(2)), Single(Value(1))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, Flags, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("From")), Atom(AtomExt("To")), Atom(AtomExt("Cc")), Atom(AtomExt("Bcc")), Atom(AtomExt("Resent-Message-ID")), Atom(AtomExt("Subject")), Atom(AtomExt("Date")), Atom(AtomExt("Message-ID")), Atom(AtomExt("Priority")), Atom(AtomExt("X-Priority")), Atom(AtomExt("References")), Atom(AtomExt("Newsgroups")), Atom(AtomExt("In-Reply-To")), Atom(AtomExt("Content-Type")), Atom(AtomExt("Reply-To")), Atom(AtomExt("List-Unsubscribe")), Atom(AtomExt("Received")), Atom(AtomExt("Delivery-Date"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(4))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(4))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Rfc822Header, BodyExt { section: Some(Part(Part([2, 1]+))), partial: None, peek: true }, BodyExt { section: Some(Part(Part([2, 2]+))), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(4))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(4)), Single(Value(3)), Single(Value(2)), Single(Value(1))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(5))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(5))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Rfc822Header, BodyExt { section: Some(Part(Part([1, 2]+))), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(5))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(5))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, Flags, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("From")), Atom(AtomExt("To")), Atom(AtomExt("Cc")), Atom(AtomExt("Bcc")), Atom(AtomExt("Subject")), Atom(AtomExt("Date")), Atom(AtomExt("Message-ID")), Atom(AtomExt("Priority")), Atom(AtomExt("X-Priority")), Atom(AtomExt("References")), Atom(AtomExt("Newsgroups")), Atom(AtomExt("In-Reply-To")), Atom(AtomExt("Content-Type")), Atom(AtomExt("Reply-To"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(60))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: Some(Part(Part([1, 2]+))), partial: Some((0, 2227)), peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(60))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: Some(Part(Part([2]+))), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(60))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyStructure, BodyExt { section: Some(Header(None)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(60))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(61))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: Some(Header(None)), partial: None, peek: true }, BodyExt { section: Some(Text(None)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(62))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: Some(Part(Part([1, 2]+))), partial: Some((0, 711)), peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(62))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: Some(Part(Part([2]+))), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(62))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyStructure, BodyExt { section: Some(Header(None)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(62))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(6))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Rfc822Header, BodyExt { section: Some(Part(Part([2]+))), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(6))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, BodyExt { section: Some(Part(Part([2]+))), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(6))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Flags, Envelope, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("References"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(6))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(7))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: Some(Header(None)), partial: None, peek: true }, BodyExt { section: Some(Text(None)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(7))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([InternalDate, Uid, Rfc822Size, Flags, ModSeq, BodyExt { section: Some(Header(None)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(7))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Rfc822Header, BodyExt { section: Some(Part(Part([1, 2]+))), partial: None, peek: true }, BodyExt { section: Some(Part(Part([2]+))), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(7))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, BodyExt { section: Some(Part(Part([1, 2]+))), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(7))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, BodyExt { section: None, partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(7)), Single(Value(6)), Single(Value(5)), Single(Value(4)), Single(Value(3)), Single(Value(2)), Single(Value(1))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([Uid, Rfc822Size, Flags, BodyExt { section: Some(HeaderFields(None, [Atom(AtomExt("From")), Atom(AtomExt("To")), Atom(AtomExt("Cc")), Atom(AtomExt("Bcc")), Atom(AtomExt("Resent-Message-ID")), Atom(AtomExt("Subject")), Atom(AtomExt("Date")), Atom(AtomExt("Message-ID")), Atom(AtomExt("Priority")), Atom(AtomExt("X-Priority")), Atom(AtomExt("References")), Atom(AtomExt("Newsgroups")), Atom(AtomExt("In-Reply-To")), Atom(AtomExt("Content-Type")), Atom(AtomExt("Reply-To")), Atom(AtomExt("List-Unsubscribe")), Atom(AtomExt("Received")), Atom(AtomExt("Delivery-Date"))]+)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(83))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([BodyExt { section: Some(Header(None)), partial: None, peek: true }, BodyExt { section: Some(Text(None)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(83))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([InternalDate, Uid, Rfc822Size, Flags, ModSeq, BodyExt { section: Some(Header(None)), partial: None, peek: true }]), uid: true } }
Fetch { sequence_set: SequenceSet([Single(Value(85))]+), modifiers: [], macro_or_item_names: MessageDataItemNames([InternalDate, Uid, Rfc822Size, Flags, ModSeq, BodyExt { section: Some(Header(None)), partial: None, peek: true }]), uid: true } }
Idle
List { reference: Other(MailboxOther(String(Quoted(Quoted("Archive."))))), mailbox_wildcard: Token(ListCharString("*")), return: [] } }
List { reference: Other(MailboxOther(String(Quoted(Quoted("INBOX."))))), mailbox_wildcard: Token(ListCharString("*")), return: [] } }
List { reference: Other(MailboxOther(String(Quoted(Quoted(""))))), mailbox_wildcard: String(Quoted(Quoted("Dataset"))), return: [] } }
List { reference: Other(MailboxOther(String(Quoted(Quoted(""))))), mailbox_wildcard: String(Quoted(Quoted("INBOX"))), return: [] } }
List { reference: Other(MailboxOther(String(Quoted(Quoted(""))))), mailbox_wildcard: String(Quoted(Quoted(""))), return: [] } }
List { reference: Other(MailboxOther(String(Quoted(Quoted(""))))), mailbox_wildcard: String(Quoted(Quoted("*"))), return: [] } }
List { reference: Other(MailboxOther(String(Quoted(Quoted(""))))), mailbox_wildcard: String(Quoted(Quoted("*"))), return: [Status([Unseen])] } }
List { reference: Other(MailboxOther(String(Quoted(Quoted(""))))), mailbox_wildcard: Token(ListCharString("INBOX")), return: [] } }
List { reference: Other(MailboxOther(String(Quoted(Quoted(""))))), mailbox_wildcard: Token(ListCharString("%")), return: [] } }
List { reference: Other(MailboxOther(String(Quoted(Quoted("Mailspring."))))), mailbox_wildcard: Token(ListCharString("*")), return: [] } }
List { reference: Other(MailboxOther(String(Quoted(Quoted("Sent."))))), mailbox_wildcard: Token(ListCharString("*")), return: [] } }
List { reference: Other(MailboxOther(String(Quoted(Quoted("Trash."))))), mailbox_wildcard: Token(ListCharString("*")), return: [] } }
Login { username: Atom(AtomExt("REDACTED")), password: /* REDACTED */ } }
Login { username: String(Quoted(Quoted("REDACTED"))), password: /* REDACTED */ } }
Login { username: String(Quoted(Quoted("REDACTED@saint-ex.deuxfleurs.org"))), password: /* REDACTED */ } }
Logout
Lsub { reference: Other(MailboxOther(String(Quoted(Quoted(""))))), mailbox_wildcard: String(Quoted(Quoted("*"))) } }
Move { sequence_set: SequenceSet([Range(Value(20), Value(21))]+), mailbox: Other(MailboxOther(String(Quoted(Quoted("Dataset"))))), uid: true } }
Move { sequence_set: SequenceSet([Single(Value(29))]+), mailbox: Other(MailboxOther(String(Quoted(Quoted("Dataset"))))), uid: true } }
Noop
Search { charset: None, criteria: And([Header(Atom(AtomExt("Message-ID")), Atom(AtomExt("<Mailbird-8d3b03f5-7737-479e-b8d7-a3cae013e72f@saint-ex.deuxfleurs.org>"))), Undeleted]+), uid: true } }
Search { charset: None, criteria: And([SequenceSet(SequenceSet([Range(Value(1), Asterisk)]+)), Deleted]+), uid: true } }
Search { charset: None, criteria: And([SequenceSet(SequenceSet([Range(Value(1), Asterisk)]+)), Not(Deleted)]+), uid: true } }
Search { charset: None, criteria: And([SequenceSet(SequenceSet([Range(Value(1), Asterisk)]+)), Since(2023-11-16)]+), uid: true } }
Search { charset: None, criteria: And([SequenceSet(SequenceSet([Range(Value(1), Asterisk)]+)), Unseen]+), uid: true } }
Search { charset: None, criteria: And([SequenceSet(SequenceSet([Range(Value(1), Value(1))]+)), Not(Deleted)]+), uid: true } }
Search { charset: None, criteria: And([SequenceSet(SequenceSet([Range(Value(1), Value(4))]+)), Not(Deleted)]+), uid: true } }
Search { charset: None, criteria: And([Since(2024-02-07), All]+), uid: false } }
Search { charset: None, criteria: And([Since(2024-02-08), All]+), uid: false } }
Search { charset: None, criteria: And([Since(2024-02-09), All]+), uid: false } }
Search { charset: None, criteria: And([Undeleted, Since(2023-11-17)]+), uid: false } }
Search { charset: None, criteria: And([Undeleted, Since(2024-02-13)]+), uid: false } }
Search { charset: None, criteria: Before(2024-02-09), uid: true } }
Search { charset: None, criteria: Since(2024-01-31), uid: true } }
Select { mailbox: Inbox, modifiers: [] } }
Select { mailbox: Inbox, modifiers: [Condstore] } }
Select { mailbox: Other(MailboxOther(Atom(AtomExt("Archive")))), modifiers: [] } }
Select { mailbox: Other(MailboxOther(Atom(AtomExt("Drafts")))), modifiers: [] } }
Select { mailbox: Other(MailboxOther(Atom(AtomExt("Mailspring")))), modifiers: [] } }
Select { mailbox: Other(MailboxOther(Atom(AtomExt("Sent")))), modifiers: [] } }
Select { mailbox: Other(MailboxOther(Atom(AtomExt("Test.Coucou")))), modifiers: [] } }
Select { mailbox: Other(MailboxOther(Atom(AtomExt("Test")))), modifiers: [] } }
Select { mailbox: Other(MailboxOther(Atom(AtomExt("Trash")))), modifiers: [] } }
Select { mailbox: Other(MailboxOther(String(Quoted(Quoted("Archive"))))), modifiers: [] } }
Select { mailbox: Other(MailboxOther(String(Quoted(Quoted("Dataset"))))), modifiers: [] } }
Select { mailbox: Other(MailboxOther(String(Quoted(Quoted("Drafts"))))), modifiers: [] } }
Select { mailbox: Other(MailboxOther(String(Quoted(Quoted("INBOX.Pourriel"))))), modifiers: [] } }
Select { mailbox: Other(MailboxOther(String(Quoted(Quoted("Mailspring"))))), modifiers: [] } }
Select { mailbox: Other(MailboxOther(String(Quoted(Quoted("Mailspring.Snoozed"))))), modifiers: [] } }
Select { mailbox: Other(MailboxOther(String(Quoted(Quoted("Sent"))))), modifiers: [] } }
Select { mailbox: Other(MailboxOther(String(Quoted(Quoted("Sent"))))), modifiers: [Condstore] } }
Select { mailbox: Other(MailboxOther(String(Quoted(Quoted("Trash"))))), modifiers: [] } }
Status { mailbox: Inbox, item_names: [Messages, Recent, UidNext, UidValidity, Unseen] } }
Status { mailbox: Inbox, item_names: [Unseen, Messages, Recent, UidNext, UidValidity, HighestModSeq] } }
Status { mailbox: Other(MailboxOther(Atom(AtomExt("Archive")))), item_names: [Unseen, Messages, Recent, UidNext, UidValidity, HighestModSeq] } }
Status { mailbox: Other(MailboxOther(Atom(AtomExt("Drafts")))), item_names: [Unseen, Messages, Recent, UidNext, UidValidity, HighestModSeq] } }
Status { mailbox: Other(MailboxOther(Atom(AtomExt("Mailspring")))), item_names: [Unseen, Messages, Recent, UidNext, UidValidity, HighestModSeq] } }
Status { mailbox: Other(MailboxOther(Atom(AtomExt("Sent")))), item_names: [Unseen, Messages, Recent, UidNext, UidValidity, HighestModSeq] } }
Status { mailbox: Other(MailboxOther(Atom(AtomExt("Trash")))), item_names: [Unseen, Messages, Recent, UidNext, UidValidity, HighestModSeq] } }
Status { mailbox: Other(MailboxOther(String(Quoted(Quoted("Archive"))))), item_names: [UidNext, Messages, Unseen, Recent] } }
Status { mailbox: Other(MailboxOther(String(Quoted(Quoted("Archive"))))), item_names: [UidNext, UidValidity, Unseen, Recent] } }
Status { mailbox: Other(MailboxOther(String(Quoted(Quoted("Drafts"))))), item_names: [Messages] } }
Status { mailbox: Other(MailboxOther(String(Quoted(Quoted("Drafts"))))), item_names: [UidNext, Messages, Unseen, Recent] } }
Status { mailbox: Other(MailboxOther(String(Quoted(Quoted("Drafts"))))), item_names: [UidNext, UidValidity, Unseen, Recent] } }
Status { mailbox: Other(MailboxOther(String(Quoted(Quoted("INBOX.Pourriel"))))), item_names: [UidNext, Messages, Unseen, Recent] } }
Status { mailbox: Other(MailboxOther(String(Quoted(Quoted("INBOX.Pourriel"))))), item_names: [Unseen, Messages, Recent, UidNext, UidValidity, HighestModSeq] } }
Status { mailbox: Other(MailboxOther(String(Quoted(Quoted("Mailspring"))))), item_names: [UidNext, Messages, Unseen, Recent] } }
Status { mailbox: Other(MailboxOther(String(Quoted(Quoted("Mailspring.Snoozed"))))), item_names: [UidNext, Messages, Unseen, Recent] } }
Status { mailbox: Other(MailboxOther(String(Quoted(Quoted("Mailspring.Snoozed"))))), item_names: [Unseen, Messages, Recent, UidNext, UidValidity, HighestModSeq] } }
Status { mailbox: Other(MailboxOther(String(Quoted(Quoted("Sent"))))), item_names: [UidNext, Messages, Unseen, Recent] } }
Status { mailbox: Other(MailboxOther(String(Quoted(Quoted("Sent"))))), item_names: [UidNext, UidValidity, Unseen, Recent] } }
Status { mailbox: Other(MailboxOther(String(Quoted(Quoted("Test.Coucou"))))), item_names: [UidNext, UidValidity, Unseen, Recent] } }
Status { mailbox: Other(MailboxOther(String(Quoted(Quoted("Test"))))), item_names: [UidNext, UidValidity, Unseen, Recent] } }
Status { mailbox: Other(MailboxOther(String(Quoted(Quoted("Trash"))))), item_names: [UidNext, UidValidity, Unseen, Recent] } }
Store { sequence_set: SequenceSet([Range(Value(60), Value(62))]+), kind: Add, response: Answer, flags: [Deleted], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Range(Value(60), Value(62))]+), kind: Add, response: Answer, flags: [Deleted, Seen], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Range(Value(60), Value(62))]+), kind: Add, response: Answer, flags: [Seen, Deleted], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Single(Value(1))]+), kind: Add, response: Answer, flags: [Seen], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Single(Value(24))]+), kind: Add, response: Answer, flags: [Seen, Deleted], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Single(Value(24))]+), kind: Add, response: Answer, flags: [Seen], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Single(Value(26))]+), kind: Add, response: Answer, flags: [Answered], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Single(Value(29))]+), kind: Add, response: Answer, flags: [Keyword(Atom("NonJunk"))], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Single(Value(29))]+), kind: Add, response: Answer, flags: [Seen], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Single(Value(2))]+), kind: Add, response: Answer, flags: [Seen], modifiers: [], uid: false } }
Store { sequence_set: SequenceSet([Single(Value(2))]+), kind: Add, response: Silent, flags: [Deleted], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Single(Value(3))]+), kind: Add, response: Answer, flags: [Seen], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Single(Value(3))]+), kind: Add, response: Silent, flags: [Seen], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Single(Value(5))]+), kind: Add, response: Answer, flags: [Keyword(Atom("NonJunk"))], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Single(Value(60))]+), kind: Add, response: Answer, flags: [Seen, Deleted], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Single(Value(60))]+), kind: Add, response: Silent, flags: [Seen], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Single(Value(61))]+), kind: Add, response: Answer, flags: [Seen, Deleted], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Single(Value(61))]+), kind: Add, response: Silent, flags: [Seen], modifiers: [], uid: true } }
Store { sequence_set: SequenceSet([Single(Value(62))]+), kind: Add, response: Silent, flags: [Seen], modifiers: [], uid: true } }
Subscribe { mailbox: Other(MailboxOther(Atom(AtomExt("Mailspring")))) } }
Subscribe { mailbox: Other(MailboxOther(String(Quoted(Quoted("Dataset"))))) } }
Subscribe { mailbox: Other(MailboxOther(String(Quoted(Quoted("Mailspring.Snoozed"))))) } }
Unselect

View file

@ -0,0 +1,45 @@
count,command,aggregation
6,Append,Raw
33,Capability,Raw
96,Check,Raw
6,Close,Raw
3,Create,Raw
9,Enable,Raw
26,Examine,Raw
1,Expunge,Raw
1187,Fetch,Raw
248,Idle,Raw
132,List,Raw
244,Login,Raw
169,Logout,Raw
14,Lsub,Raw
2,Move,Raw
295,Noop,Raw
658,Search,Raw
746,Select,Raw
203,Status,Raw
23,Store,Raw
3,Subscribe,Raw
515,Unselect,Raw
6,Append,Unique
1,Capability,Unique
1,Check,Unique
1,Close,Unique
3,Create,Unique
2,Enable,Unique
1,Examine,Unique
1,Expunge,Unique
128,Fetch,Unique
1,Idle,Unique
12,List,Unique
9,Login,Unique
1,Logout,Unique
1,Lsub,Unique
2,Move,Unique
1,Noop,Unique
14,Search,Unique
18,Select,Unique
22,Status,Unique
19,Store,Unique
3,Subscribe,Unique
1,Unselect,Unique
1 count command aggregation
2 6 Append Raw
3 33 Capability Raw
4 96 Check Raw
5 6 Close Raw
6 3 Create Raw
7 9 Enable Raw
8 26 Examine Raw
9 1 Expunge Raw
10 1187 Fetch Raw
11 248 Idle Raw
12 132 List Raw
13 244 Login Raw
14 169 Logout Raw
15 14 Lsub Raw
16 2 Move Raw
17 295 Noop Raw
18 658 Search Raw
19 746 Select Raw
20 203 Status Raw
21 23 Store Raw
22 3 Subscribe Raw
23 515 Unselect Raw
24 6 Append Unique
25 1 Capability Unique
26 1 Check Unique
27 1 Close Unique
28 3 Create Unique
29 2 Enable Unique
30 1 Examine Unique
31 1 Expunge Unique
32 128 Fetch Unique
33 1 Idle Unique
34 12 List Unique
35 9 Login Unique
36 1 Logout Unique
37 1 Lsub Unique
38 2 Move Unique
39 1 Noop Unique
40 14 Search Unique
41 18 Select Unique
42 22 Status Unique
43 19 Store Unique
44 3 Subscribe Unique
45 1 Unselect Unique

File diff suppressed because it is too large Load diff

14
tests/emails/report.R Normal file
View file

@ -0,0 +1,14 @@
library(tidyverse)
library(lubridate)
read_csv("imap_commands_summary.csv") -> cmd
ggplot(cmd, aes(x=command, y=count)) +
geom_bar(stat = "identity")+
theme_classic() +
facet_wrap(~aggregation, ncol=1, scales = "free")
read_csv("mailbox_email_sizes.csv") -> mbx
ggplot(mbx, aes(x=size, colour=mailbox)) +
stat_ecdf(pad=FALSE,geom = "step") +
scale_x_log10()+ theme_classic()

View file

@ -1,7 +1,7 @@
version: '3.4'
services:
dovecot:
image: dovecot/dovecot:2.3.19.1
image: dovecot/dovecot:2.3.21
ports:
- "993:993/tcp"
@ -20,3 +20,10 @@ services:
- "/dev/log:/dev/log"
ports:
- "143:143/tcp"
courier:
build:
context: ./docker/courier/
image: courier
ports:
- "144:143/tcp"

View file

@ -0,0 +1,10 @@
FROM debian:sid
RUN apt-get update
RUN apt-get install -y courier-imap
RUN useradd -m debian -p '$1$B8Mq5Hki$fg5f4SndVNWsfq.mJiqbI0'
USER debian
RUN maildirmake /home/debian/Maildir
USER root
COPY ./entrypoint.sh /entrypoint
ENTRYPOINT ["/entrypoint"]

View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
/usr/lib/courier/courier-authlib/authdaemond &
/usr/sbin/couriertcpd -address=0 -maxprocs=40 -maxperip=20 -access=/etc/courier/imapaccess.dat -nodnslookup -noidentlookup 143 /usr/lib/courier/courier/imaplogin /usr/bin/imapd Maildir

View file

@ -1,4 +1,4 @@
FROM debian:buster
FROM debian:sid
ARG DEBIAN_FRONTEND=noninteractive
RUN apt update && \
@ -6,7 +6,7 @@ RUN apt update && \
echo "admins: cyrus" >> /etc/imapd.conf && \
touch /var/lib/cyrus/tls_sessions.db && \
chown cyrus:mail /var/lib/cyrus/tls_sessions.db && \
mkdir /run/cyrus && \
mkdir -p /run/cyrus && \
chown -R cyrus:mail /run/cyrus
COPY entrypoint.sh /usr/local/bin/entrypoint.sh

View file

@ -4,7 +4,7 @@ WORKDIR /root
RUN apt-get update && apt-get install -y openssl && \
openssl req -nodes -new -x509 -subj "/C=DW/ST=Sto/L=Ankh-Morpork /O=Unseen University/OU=Library/CN=Ook/emailAddress=ook@ook.ook" -keyout privkey.pem -out fullchain.pem
FROM foxcpp/maddy:0.6.2
FROM foxcpp/maddy:0.7.0
COPY --from=builder /root/privkey.pem /data/tls/privkey.pem
COPY --from=builder /root/fullchain.pem /data/tls/fullchain.pem

View file

@ -0,0 +1,9 @@
#!/bin/sh
maddy -config /data/maddy.conf run &
sleep 2
maddy creds create --password pass test@example.com
maddy imap-acct create test@example.com
wait

View file

@ -0,0 +1,39 @@
from imaplib import IMAP4_SSL, IMAP4
from os import listdir
from os.path import isfile, join
import sys
import argparse
import mailbox
parser = argparse.ArgumentParser(
prog='mbox-to-imap',
description='Send an mbox to an imap server',
epilog='Just a debug tool')
parser.add_argument('mbox_path') # positional argument
parser.add_argument('-H', '--host', default="localhost")
parser.add_argument('-p', '--port', default="143")
parser.add_argument('-u', '--user')
parser.add_argument('-s', '--password')
parser.add_argument('-m', '--mailbox', default="INBOX")
parser.add_argument('-t', '--tls', action='store_true')
args = parser.parse_args()
mbox = mailbox.mbox(args.mbox_path)
if args.tls:
imap = IMAP4_SSL
else:
imap = IMAP4
print(args)
with imap(host=args.host, port=args.port) as M:
print(M.login(args.user, args.password))
print(M.select(args.mailbox))
for k in mbox.keys():
content = mbox.get(k).as_bytes()
M.append(args.mailbox, [], None, content)
print(f"{k}/{len(mbox)}")

View file

@ -51,6 +51,14 @@ parameters = {
"ext": ".cyrus",
"mb": "INBOX."+base_test_mb,
},
"courier": {
"con": IMAP4,
"port": 144,
"user": "debian",
"pw": "debian",
"ext": ".courier",
"mb": base_test_mb,
},
"stalwart": {
"con": IMAP4_SSL,
"port": 1993,