C12.1: ely-mail-engine connects over IMAP, POP3 and SMTP
TLS or STARTTLS with the system trust store; password and XOAUTH2 logins keep the server's words on refusal. IMAP and POP3 are written in the engine (imap-proto parses IMAP replies): async-imap dropped NO/BAD on LIST and FETCH and lost BYE text, async-pop kept dot stuffing and logged passwords. SMTP runs its own transaction with RFC 5321 mailbox checks, full dot transparency, SMTPUTF8 and RSET. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d7de5619a7
commit
9f47b94714
21 files changed
+2848
-20
No files matched your search
Generated
+359
-14
@@ -290,7 +290,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -301,7 +301,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -410,6 +410,45 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.21",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-derive"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure 0.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs-impl"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -755,6 +794,9 @@ name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
@@ -1648,6 +1690,12 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
@@ -1706,6 +1754,20 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der-parser"
|
||||
version = "10.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -1805,7 +1867,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.3",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2067,6 +2129,25 @@ dependencies = [
|
||||
"embed-resource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ely-mail-engine"
|
||||
version = "0.1.8"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"ely-platform",
|
||||
"imap-proto",
|
||||
"log",
|
||||
"mail-send",
|
||||
"nom 8.0.0",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
"rustls-platform-verifier",
|
||||
"smtp-proto",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ely-mark"
|
||||
version = "0.1.8"
|
||||
@@ -2405,7 +2486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3793,6 +3874,15 @@ version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
|
||||
|
||||
[[package]]
|
||||
name = "imap-proto"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ccf963d57074747b455398a1763d174da80bcaba6f51e3671a82252b531a68b"
|
||||
dependencies = [
|
||||
"nom 8.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imgref"
|
||||
version = "1.12.3"
|
||||
@@ -4044,6 +4134,36 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.21",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@@ -4436,6 +4556,22 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mail-send"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8355326d14f08c9de5e9badfe606d9ba8fea2febb60fe4318ecbac33cfb7b41e"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"gethostname",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"smtp-proto",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@@ -4611,7 +4747,7 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4776,7 +4912,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5320,6 +5456,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -5377,6 +5522,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -5481,6 +5632,16 @@ dependencies = [
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -6259,6 +6420,20 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
|
||||
dependencies = [
|
||||
"pem",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"x509-parser",
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.37.0"
|
||||
@@ -6529,6 +6704,15 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||
dependencies = [
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.5"
|
||||
@@ -6539,7 +6723,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6568,6 +6752,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework 3.7.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
@@ -6577,6 +6773,33 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.0",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
@@ -6639,6 +6862,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.2"
|
||||
@@ -6976,6 +7208,16 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd_helpers"
|
||||
version = "0.1.0"
|
||||
@@ -7084,6 +7326,25 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smtp-proto"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "142a5a642c6bd7ffd7e1b525ad6a6e9921ccef992dba4663974f8519209a00c1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
@@ -7127,7 +7388,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7377,6 +7638,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.14.0"
|
||||
@@ -7478,7 +7750,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7643,6 +7915,42 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
@@ -7828,7 +8136,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8369,6 +8677,15 @@ dependencies = [
|
||||
"string_cache_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
@@ -8633,7 +8950,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9288,7 +9605,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"dunce",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2 0.6.4",
|
||||
@@ -9340,6 +9657,24 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "x509-parser"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||
dependencies = [
|
||||
"asn1-rs",
|
||||
"data-encoding",
|
||||
"der-parser",
|
||||
"lazy_static",
|
||||
"nom 7.1.3",
|
||||
"oid-registry",
|
||||
"ring",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.21",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xcb"
|
||||
version = "1.7.1"
|
||||
@@ -9412,6 +9747,16 @@ version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
|
||||
dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yazi"
|
||||
version = "0.2.1"
|
||||
@@ -9449,7 +9794,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9663,7 +10008,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
"synstructure 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -56,10 +56,19 @@ core-foundation = "0.10"
|
||||
objc2 = "0.6"
|
||||
security-framework = "3.7"
|
||||
objc2-pdf-kit = { version = "0.3", default-features = false, features = ["PDFDocument", "PDFPage", "objc2-app-kit"] }
|
||||
imap-proto = "0.17"
|
||||
nom = "8"
|
||||
mail-send = { version = "0.6", default-features = false, features = ["ring", "tls12"] }
|
||||
rcgen = "0.14"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "tls12", "std"] }
|
||||
rustls-platform-verifier = "0.7"
|
||||
tokio = { version = "1", features = ["net", "time", "io-util", "rt", "macros"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
|
||||
|
||||
ely-files = { path = "crates/ely-files" }
|
||||
ely-ipc = { path = "crates/ely-ipc" }
|
||||
ely-lok = { path = "crates/ely-lok" }
|
||||
ely-mail-engine = { path = "crates/ely-mail-engine" }
|
||||
ely-office = { path = "crates/ely-office" }
|
||||
ely-pdf-engine = { path = "crates/ely-pdf-engine" }
|
||||
ely-platform = { path = "crates/ely-platform" }
|
||||
|
||||
@@ -22,7 +22,7 @@ Ely Office 提供八个桌面应用。用户可以写文档、计算表格、制
|
||||
| ElyTable | 电子表格编辑与计算 | LOK | XLSX、ODS、CSV |
|
||||
| ElyShow | 演示文稿编辑与放映 | LOK | PPTX、ODP |
|
||||
| ElyPDF | PDF 阅读、编辑、批注、表单和页面处理 | MuPDF;OCR 复用 Tesseract | PDF |
|
||||
| ElyMail | 邮件收发、本地存储和搜索 | Rust 的 async-imap、async-pop、mail-send、mail-parser、mail-builder,加本地同步与存储 | 邮件、附件、草稿 |
|
||||
| ElyMail | 邮件收发、本地存储和搜索 | Rust 的 mail-send、mail-parser、mail-builder(IMAP 与 POP3 由引擎自写),加本地同步与存储 | 邮件、附件、草稿 |
|
||||
| ElyTree | Git 图形化版本管理 | Git CLI | 本地 Git 仓库 |
|
||||
| ElyMark | 本地 Markdown 编辑 | Ely-GPUI-Components 的 Markdown 文档组件,markdown-rs 解析;参考 MarkText、ColaMD、Markion | Markdown 与本地附件 |
|
||||
| ElyAgent | 独立的 Agent 配置与会话管理 | 嵌入的 Kigi CLI Rust 代码 | 配置、会话记录、运行状态 |
|
||||
@@ -375,7 +375,7 @@ XFA 动态表单、任意嵌入脚本执行、专业印前分色及 PDF 到 Offi
|
||||
|
||||
### 12.1 定位与后端
|
||||
|
||||
提供多账号邮件客户端,不建设邮件托管服务。协议与 MIME 用 Rust crate(用户 2026-10-19 定,取代 MailCore 2):async-imap 收 IMAP,async-pop 收 POP3,mail-send 发 SMTP,mail-parser 读 MIME,mail-builder 写 MIME。它们在应用进程的后台任务里运行,不另起工作进程。Ely 负责账号流程、本地存储、搜索、同步队列和界面。
|
||||
提供多账号邮件客户端,不建设邮件托管服务。协议与 MIME 用 Rust crate(用户 2026-10-19 定,取代 MailCore 2):IMAP(imap-proto 解析)与 POP3 由 Ely 自写(async-imap 与 async-pop 有数处缺陷,C12.1 审查查出),mail-send 发 SMTP,mail-parser 读 MIME,mail-builder 写 MIME。它们在应用进程的后台任务里运行,不另起工作进程。Ely 负责账号流程、本地存储、搜索、同步队列和界面。
|
||||
|
||||
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文与写信不用 CEF 与 Electron(用户 2026-10-19 定):正文以 html5ever 解析,净化后按块的子集由 GPUI 绘制(段落、标题、列表、引用、表格、图片、链接、粗斜体与颜色),脚本、定位、浮动与样式表之外的版式不画,不当作完整页面渲染;写信用组件库的 Markdown 文档组件,发出纯文本与由 markdown-rs 生成的 HTML 两部分。
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
| Doc / Table / Show | LibreOfficeKit,独立工作进程 |
|
||||
| Show 媒体 | 各平台原生:macOS AVFoundation,Windows Media Foundation,Linux GStreamer(LOK 不放媒体) |
|
||||
| PDF | MuPDF(C 接口),Tesseract OCR,独立工作进程 |
|
||||
| Mail | async-imap、async-pop、mail-send、mail-parser、mail-builder(`crates/ely-mail-engine`,应用进程内的后台任务,无 worker)+ SQLite(FTS5);正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制;写信用 `documents::MarkdownDocument`,发出纯文本与 HTML;Gmail、Microsoft 走 OAuth,iCloud 走应用专用密码 |
|
||||
| Mail | mail-send、mail-parser、mail-builder,IMAP(imap-proto 解析)与 POP3 由引擎自写(`crates/ely-mail-engine`,应用进程内的后台任务,无 worker)+ SQLite(FTS5);正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制;写信用 `documents::MarkdownDocument`,发出纯文本与 HTML;Gmail、Microsoft 走 OAuth,iCloud 走应用专用密码 |
|
||||
| Tree | Git CLI,参数数组启动 |
|
||||
| Mark | 组件库 `documents::MarkdownDocument`(GPUI,原文即文本,无序列化);markdown-rs 解析与导出 HTML,RaTeX 公式,mermaid-rs-renderer 图;PDF 经 `ely-pdf-worker` 由 MuPDF 排版 |
|
||||
| Agent | Kigi CLI 的 Rust 代码直接嵌入(不用 ACP),沿用其提供商登录与模型配置 |
|
||||
@@ -320,4 +320,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-18:Word 公式(R8.2,Gitea #9)。附件 math.docx 的六类公式逐类核对(本机无 Word,以文件所写为准):键盘输入、OMML(内核转为公式对象)、MathType(OLE,显示随带的 WMF 预览)、上下标、图片原本就对;差别在两处根因。其一,运行的 `w:position`(升降)原先只给文字,图片与 OLE 公式所在的运行失去它(25 张图片与一个 ½ 浮在行上):补丁 `0049-writer-inline-objects-move-with-their-run.patch` 把它作为固定升降写到对象的锚字符上(按锚字符自己的字号换算百分比,不扩展到其后的文字),版面让“作为字符”的对象随锚字符的固定升降移动(vertAlign 的自动上下标不动对象,同原样);各导出本就写出锚字符的升降,DOCX 往返仍为 `w:position`。其二,EQ 域原先只认 `\o`、`\s\do` 与注音,其余(如分数 `\f`)整个消失:补丁 `0050-writer-eq-field-parser.patch`(读法)与 `0051-writer-eq-fields-as-formulas.patch`(导入)把 EQ 域转为公式对象:`\f` 分数、`\r` 根式、`\s` 上下标、`\b` 括号、`\l` 列表、`\a` 阵列、`\i` 积分与求和求积;`\x` 与 `\o` 只留内容(无框、无叠印),`\d` 只前移,`\ai`、`\di` 只是行距(不升降),开关不分大小写;每个命令字符保留其运行的上下标(Word 照此画),同一段上下标里字样变了仍是一个上下标;斜体字母写成标识符(StarMath 与 OMML 都画成斜体,DOCX 往返不丢),斜体的数字与符号写成 `ital` 文字,存为 DOCX 后成正体(OMML 无此写法,已知限制);没有底的上下标以空文字为底(空的底在 OMML 里是占位符,重开成 `<?>`);公式取域的字号;读不通的命令以其域代码显示为文字,不消失。只有整个域是 `\s\do N(文字)` 时照原样成为下移的文字,其余都经读法(原先小写的 `\s\do` 读到第一个括号即止,其后丢失)。`client/tests/word.rs` 以 `tests/fixtures/office/word.docx`(由 `.work/r8/gen/eq.py` 生成,不入库)核对公式文本(含开头的空格、大写开关、读不通的域、字样变化的下标、`\ai`、`\di`、小写 `\s\do` 的尾部与嵌套)、DOCX 往返后的斜体、下标与无占位符、锚字符的升降与图块里图片低于文字(去掉版面的升降即失败)。审查三轮:第 3 轮的四处已修并以上述测试核对,未再审查(每条至多三轮)。仍与 Word 不同:编辑区里公式与图片四周有内核的辅助框线(`ShowBoundaries`,视图辅助,不印出);DOCX 另存后重开行距变宽,因内核导出改写文档默认字体与段距,与公式无关。
|
||||
- 2026-10-18:工作表过多时标签条(R8.3,Gitea #8)。组件库 `navigation::Tabs` 横排时可横向滚动(滚轮同样),标签可窄于自然宽度;所选标签在所选或标签条宽度变了时滚入视口,在绘制时以刚排好的宽度判定(原先只在首帧滚一次,其后右栏打开、标签条变窄,所选标签被挤出视口);手动滚动保留到二者之一再变;横排的标签至多与标签条同宽,名字过长以省略号结尾(组件库 e56a343、3a68d1b、7d704d9,同批带入上游列外壳拖动的四个提交)。ElyTable 的标签条随之收缩,添加按钮留在其旁;标签条的状态按工作簿各记一份(元素 id 带标签号),切到另一工作簿时其所选标签照样滚入。`navigation/tests/tabs.rs` 以 30 个标签核对:首帧滚到所选、滚轮回到第一个、新选的滚入、变窄后所选仍在末端(去掉按宽度判定即失败)、长名的所选标签整个在标签条内(去掉宽度上限即失败);ElyTable 的 `tests/strip.rs` 以 `sheets::strip` 画两个各有 30 张表、都选第 30 张的工作簿,滚回开头后来回切换,所选标签每次都在标签条内(两个工作簿共用一个 id 即失败)。Windows 真窗口(`.work/r8/strip3.png`):30 张表、第 30 张为当前,1100 宽时标签条末端为它。
|
||||
- 2026-10-18:ElyMark 公式(R8.4,用户定两项都做)。行内公式照块公式由 RaTeX 排版(原只显示着色的 TeX 原文):gpui 的文本里放不进图片,组件库 `render/math_text.rs` 把每个行内公式换成等宽的不断行空格(U+00A0,按字体量出个数),排好的公式居中画在其上,行高随最高的公式撑开;空格串与词一样整个折到下一行,画在其所在行(折行处的序号 gpui 读作上一行末);按下与右键所在的字节经换算读回公式的原文;排不出的公式照旧显示 TeX 原文,其后以危险色写出 RaTeX 给的原因与位置,记警告(不含公式)。`\(…\)` 读作行内公式,`\[…\]` 读作块公式(ChatGPT 等常写的分隔符;GitHub 不认,Typora 默认不认):解析前逐字节把成对的分隔符换成 `$`(长度不变,位置照旧对得上)。成对只在同一段落、标题或单元格里(跨列表项、CR 空行不成对);不跨代码、公式、HTML 标签、链接地址与引用标签、图片、脚注引用,`<code>`、`<kbd>` 之间(含嵌套;交叉闭合照可视的读法关闭与重开)的文字照原文;`\\(` 不换。公式里与段落里另有的 `$`(含 `\$`)解析时暂写成文本里没有的控制字节,解析后还原,不与换上的 `$` 配对;相邻的两个公式之间暂插一个空格,解析后去掉,仍是两个公式。公式节点的范围是整对分隔符,值是写下的 TeX 原文。独占一段的 `\[…\]` 为块公式(段首可有一至三个空格)。组件库公开 `documents::markdown_tree`,ElyMark 的 HTML、PDF、纯文本导出都经它解析(`markdown_options` 不再公开),纯文本里独占一段的公式照块公式写其 TeX。同时查出(组件库 e095f06):RaTeX 的 SVG 以 pt 定尺寸,usvg 按 px 量墨迹,两者差 4/3,每个公式的画面右侧与下方多出约四分之一的空白(块公式因此偏左)、导出的公式比正文大 4/3;现生成时即把根改为 px。`markdown/tex/tests.rs` 与 `tree/tests.rs` 核对分隔符的各种情形(相邻、`\$`、链接地址、`<code>`、跨块),`math_text.rs` 的窗口测试核对公式排进并读回原文、折到第二行的公式画在第二行首(改回按起点定位即失败)、排不出的公式写出原因(组件库 a20e712、5fba7b3、de961eb),`math/tests.rs` 核对画面与公式同宽(改回 pt 即失败),ElyMark 的 `visual.rs` 核对导出与纯文本。Windows 真窗口(`.work/r8/mark/visual2.png`):`$…$`、`\(…\)` 排在行内,`\[…\]` 居中成块,行内代码与转义的 `\\(` 照原文。
|
||||
- 2026-10-19(用户定):ElyMail 的后端改为 Rust crate,取代 MailCore 2(其 Windows 构建是 VS 2013 工程加 2015 年前后的预编译依赖,POP 无 OAuth2):async-imap、async-pop、mail-send、mail-parser、mail-builder,在应用进程的后台任务里运行,不另起 worker;MailCore 2 与 libetpan 的取码、构建与探测随之删去。正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制,不当作完整页面;写信用 Markdown 文档组件,发出纯文本与 HTML。Gmail 与 Microsoft 走 OAuth(授权码加 PKCE,本机回环),客户端 ID 在构建时注入:`ELY_GOOGLE_CLIENT_ID`、`ELY_GOOGLE_CLIENT_SECRET`、`ELY_MICROSOFT_CLIENT_ID`,本机写在项目根的 `oauth.env`(不入库),CI 为仓库机密;缺 ID 的构建里该登录写明不可用,不退回别的方式。iCloud 不给第三方 OAuth,走应用专用密码向导(`imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)。注册由用户做(Google Desktop 客户端,Entra 公共客户端,委托权限 IMAP、POP、SMTP 与 offline_access)。
|
||||
- 2026-10-19(用户定):ElyMail 的后端改为 Rust crate,取代 MailCore 2(其 Windows 构建是 VS 2013 工程加 2015 年前后的预编译依赖,POP 无 OAuth2):mail-send、mail-parser、mail-builder,在应用进程的后台任务里运行,不另起 worker;MailCore 2 与 libetpan 的取码、构建与探测随之删去。正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制,不当作完整页面;写信用 Markdown 文档组件,发出纯文本与 HTML。Gmail 与 Microsoft 走 OAuth(授权码加 PKCE,本机回环),客户端 ID 在构建时注入:`ELY_GOOGLE_CLIENT_ID`、`ELY_GOOGLE_CLIENT_SECRET`、`ELY_MICROSOFT_CLIENT_ID`,本机写在项目根的 `oauth.env`(不入库),CI 为仓库机密;缺 ID 的构建里该登录写明不可用,不退回别的方式。iCloud 不给第三方 OAuth,走应用专用密码向导(`imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)。注册由用户做(Google Desktop 客户端,Entra 公共客户端,委托权限 IMAP、POP、SMTP 与 offline_access)。POP3(C12.1 审查后)由引擎自写,不用 async-pop:它留着点填充、丢末尾 CRLF、吞掉登录后的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码。IMAP(第 2 轮审查后)同样自写,以 imap-proto 解析回复,不用 async-imap:它的 LIST、FETCH 等流式命令不看结尾的 NO 与 BAD(拒绝成了空结果),登录与命令中途的 `* BYE` 被丢掉(只剩断线),trace 日志写出密码与邮件。SMTP 的事务(MAIL、RCPT、DATA)也由引擎自走:mail-send 不给首行的点加点。
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "ely-mail-engine"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
imap-proto.workspace = true
|
||||
base64.workspace = true
|
||||
log.workspace = true
|
||||
mail-send.workspace = true
|
||||
nom.workspace = true
|
||||
rustls.workspace = true
|
||||
rustls-platform-verifier.workspace = true
|
||||
smtp-proto = "0.2"
|
||||
tokio.workspace = true
|
||||
tokio-rustls.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ely-platform.workspace = true
|
||||
rcgen.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,38 @@
|
||||
use std::{fmt, io};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Why a mail server conversation failed; server words kept.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// No TCP connection: name lookup, refusal, reset.
|
||||
Connect(io::Error),
|
||||
/// TLS handshake or certificate check failed.
|
||||
Tls(String),
|
||||
/// The server refused the credentials.
|
||||
Rejected(String),
|
||||
/// The server refused a command after login.
|
||||
Server(String),
|
||||
/// The server said something the client cannot read.
|
||||
Protocol(String),
|
||||
/// The server did not answer in time.
|
||||
Timeout(&'static str),
|
||||
/// An address or login the protocol cannot carry.
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Connect(err) => write!(f, "cannot connect: {err}"),
|
||||
Self::Tls(text) => write!(f, "TLS failed: {text}"),
|
||||
Self::Rejected(text) => write!(f, "login refused: {text}"),
|
||||
Self::Server(text) => write!(f, "server refused: {text}"),
|
||||
Self::Protocol(text) => write!(f, "unreadable reply: {text}"),
|
||||
Self::Timeout(step) => write!(f, "no answer while {step}"),
|
||||
Self::Invalid(text) => write!(f, "cannot be sent: {text}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
@@ -0,0 +1,325 @@
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use imap_proto::{AttributeValue, MailboxDatum, Response, Status};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
use crate::{
|
||||
Error, Result,
|
||||
net::{self, Login, Secret, Security, Server, Stream, within},
|
||||
wire::{Wire, words},
|
||||
};
|
||||
|
||||
/// A logged-in IMAP session over TLS.
|
||||
pub struct Imap {
|
||||
conn: Conn<Stream>,
|
||||
}
|
||||
|
||||
/// One command argument.
|
||||
enum Arg<'a> {
|
||||
/// Written as is.
|
||||
Atom(&'a str),
|
||||
/// Quoted, or a literal when quotes cannot carry it.
|
||||
Text(&'a str),
|
||||
}
|
||||
|
||||
/// OK, or the words of NO or BAD.
|
||||
type Outcome = std::result::Result<(), String>;
|
||||
|
||||
impl Imap {
|
||||
pub async fn connect(server: &Server, login: &Login, tls: &TlsConnector) -> Result<Self> {
|
||||
let tcp = net::tcp(server).await?;
|
||||
let stream = match server.security {
|
||||
Security::Tls => net::tls(tcp, server, tls).await?,
|
||||
Security::StartTls => {
|
||||
let mut plain = Conn::new(tcp);
|
||||
within("waiting for the greeting", plain.greeting()).await??;
|
||||
within("starting TLS", plain.run("STARTTLS", &[], |_, _| {}))
|
||||
.await??
|
||||
.map_err(Error::Server)?;
|
||||
net::tls(plain.wire.into_inner()?, server, tls).await?
|
||||
}
|
||||
};
|
||||
let mut conn = Conn::new(stream);
|
||||
if server.security == Security::Tls {
|
||||
within("waiting for the greeting", conn.greeting()).await??;
|
||||
}
|
||||
let outcome = match &login.secret {
|
||||
Secret::Password(password) => {
|
||||
let args = [Arg::Text(&login.user), Arg::Text(password)];
|
||||
within("logging in", conn.run("LOGIN", &args, |_, _| {})).await??
|
||||
}
|
||||
Secret::Token(token) => {
|
||||
within("logging in", conn.xoauth2(&login.xoauth2(token))).await??
|
||||
}
|
||||
};
|
||||
outcome.map_err(Error::Rejected)?;
|
||||
log::info!("mail: IMAP login on {}", server.host);
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
/// Every mailbox name the server lists.
|
||||
pub async fn mailboxes(&mut self) -> Result<Vec<String>> {
|
||||
let mut names = Vec::new();
|
||||
let args = [Arg::Text(""), Arg::Atom("*")];
|
||||
within(
|
||||
"listing mailboxes",
|
||||
self.conn.run("LIST", &args, |response, raw| {
|
||||
if let Response::MailboxData(MailboxDatum::List(list)) = response {
|
||||
names.push(mailbox_name(&list.name, raw));
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await??
|
||||
.map_err(Error::Server)?;
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// The raw message; `None` once the UID is gone.
|
||||
pub async fn fetch(&mut self, mailbox: &str, uid: u32) -> Result<Option<Vec<u8>>> {
|
||||
let examine = [Arg::Text(mailbox)];
|
||||
within(
|
||||
"opening the mailbox",
|
||||
self.conn.run("EXAMINE", &examine, |_, _| {}),
|
||||
)
|
||||
.await??
|
||||
.map_err(Error::Server)?;
|
||||
let number = uid.to_string();
|
||||
let args = [Arg::Atom(&number), Arg::Atom("BODY.PEEK[]")];
|
||||
let mut found = None;
|
||||
within(
|
||||
"fetching the message",
|
||||
self.conn.run("UID FETCH", &args, |response, _| {
|
||||
if let Response::Fetch(_, values) = response
|
||||
&& values.contains(&AttributeValue::Uid(uid))
|
||||
{
|
||||
found = Some(body(values));
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await??
|
||||
.map_err(Error::Server)?;
|
||||
match found {
|
||||
None => Ok(None),
|
||||
Some(None) => Err(Error::Protocol(format!("UID {uid} came without a body"))),
|
||||
Some(Some(body)) => Ok(Some(body)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout(mut self) -> Result<()> {
|
||||
within("logging out", self.conn.run("LOGOUT", &[], |_, _| {}))
|
||||
.await??
|
||||
.map_err(Error::Server)
|
||||
}
|
||||
}
|
||||
|
||||
/// imap-proto keeps quoted escapes; a literal is as sent.
|
||||
fn mailbox_name(name: &str, raw: &[u8]) -> String {
|
||||
let line = raw.strip_suffix(b"\r\n").unwrap_or(raw);
|
||||
let name_end = |end: &[u8], mark: &[u8]| {
|
||||
end.strip_suffix(name.as_bytes())
|
||||
.is_some_and(|rest| rest.ends_with(mark))
|
||||
};
|
||||
let literal = name_end(line, b"}\r\n");
|
||||
let quoted = line
|
||||
.strip_suffix(b"\"")
|
||||
.is_some_and(|end| name_end(end, b"\""));
|
||||
if literal || !quoted {
|
||||
return name.to_owned();
|
||||
}
|
||||
let mut plain = String::with_capacity(name.len());
|
||||
let mut chars = name.chars();
|
||||
while let Some(char) = chars.next() {
|
||||
match char {
|
||||
'\\' => plain.extend(chars.next()),
|
||||
_ => plain.push(char),
|
||||
}
|
||||
}
|
||||
plain
|
||||
}
|
||||
|
||||
fn body(values: &[AttributeValue]) -> Option<Vec<u8>> {
|
||||
values.iter().find_map(|value| match value {
|
||||
AttributeValue::BodySection {
|
||||
section: None,
|
||||
data: Some(data),
|
||||
..
|
||||
} => Some(data.to_vec()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
struct Conn<S> {
|
||||
wire: Wire<S>,
|
||||
tags: u32,
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + AsyncWrite + Unpin> Conn<S> {
|
||||
fn new(io: S) -> Self {
|
||||
Self {
|
||||
wire: Wire::new(io),
|
||||
tags: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `* BYE` ends with the server's words.
|
||||
async fn greeting(&mut self) -> Result<()> {
|
||||
self.wire
|
||||
.next(|response, raw| match response {
|
||||
Response::Data {
|
||||
status: Status::Ok, ..
|
||||
} => Ok(()),
|
||||
Response::Data {
|
||||
status: Status::Bye,
|
||||
..
|
||||
} => Err(Error::Server(words(raw))),
|
||||
Response::Data { status, .. } => Err(Error::Protocol(format!(
|
||||
"the greeting was {status:?}, not OK"
|
||||
))),
|
||||
_ => Err(Error::Protocol("the first line was not a greeting".into())),
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
fn tag(&mut self) -> String {
|
||||
self.tags += 1;
|
||||
format!("A{}", self.tags)
|
||||
}
|
||||
|
||||
/// Sends a command; untagged responses go to `each`.
|
||||
async fn run(
|
||||
&mut self,
|
||||
verb: &str,
|
||||
args: &[Arg<'_>],
|
||||
mut each: impl FnMut(&Response, &[u8]),
|
||||
) -> Result<Outcome> {
|
||||
let tag = self.tag();
|
||||
log::debug!("mail: IMAP {tag} {verb}");
|
||||
let mut line = format!("{tag} {verb}").into_bytes();
|
||||
for arg in args {
|
||||
line.push(b' ');
|
||||
match arg {
|
||||
Arg::Atom(atom) => line.extend_from_slice(atom.as_bytes()),
|
||||
Arg::Text(text) => match quoted(text)? {
|
||||
Some(quoted) => line.extend_from_slice(quoted.as_bytes()),
|
||||
None => {
|
||||
line.extend_from_slice(format!("{{{}}}\r\n", text.len()).as_bytes());
|
||||
self.wire.write(&line).await?;
|
||||
line.clear();
|
||||
if let Err(words) = self.proceed(&tag, &mut each).await? {
|
||||
return Ok(Err(words));
|
||||
}
|
||||
line.extend_from_slice(text.as_bytes());
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
line.extend_from_slice(b"\r\n");
|
||||
self.wire.write(&line).await?;
|
||||
loop {
|
||||
match self.step(&tag, &mut each).await? {
|
||||
Step::Done(outcome) => return Ok(outcome),
|
||||
Step::Untagged => {}
|
||||
Step::Continue => {
|
||||
return Err(Error::Protocol(
|
||||
"the server asked for more than was sent".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for `+` before a literal.
|
||||
async fn proceed(
|
||||
&mut self,
|
||||
tag: &str,
|
||||
each: &mut impl FnMut(&Response, &[u8]),
|
||||
) -> Result<Outcome> {
|
||||
loop {
|
||||
match self.step(tag, each).await? {
|
||||
Step::Continue => return Ok(Ok(())),
|
||||
Step::Done(Ok(())) => {
|
||||
return Err(Error::Protocol(
|
||||
"the command ended before its literal".into(),
|
||||
));
|
||||
}
|
||||
Step::Done(refused) => return Ok(refused),
|
||||
Step::Untagged => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers the first challenge; empty after, so a refusal ends.
|
||||
async fn xoauth2(&mut self, initial: &str) -> Result<Outcome> {
|
||||
let tag = self.tag();
|
||||
self.wire
|
||||
.write(format!("{tag} AUTHENTICATE XOAUTH2\r\n").as_bytes())
|
||||
.await?;
|
||||
let mut answer = Some(STANDARD.encode(initial));
|
||||
loop {
|
||||
match self.step(&tag, &mut |_, _| {}).await? {
|
||||
Step::Continue => {
|
||||
let line = answer.take().unwrap_or_default() + "\r\n";
|
||||
self.wire.write(line.as_bytes()).await?;
|
||||
}
|
||||
Step::Done(outcome) => return Ok(outcome),
|
||||
Step::Untagged => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn step(&mut self, tag: &str, each: &mut impl FnMut(&Response, &[u8])) -> Result<Step> {
|
||||
self.wire
|
||||
.next(|response, raw| match response {
|
||||
Response::Continue(_) => Ok(Step::Continue),
|
||||
Response::Done {
|
||||
tag: done, status, ..
|
||||
} => {
|
||||
if done.0 != tag {
|
||||
return Err(Error::Protocol(format!(
|
||||
"a reply for {} came while waiting for {tag}",
|
||||
done.0
|
||||
)));
|
||||
}
|
||||
match status {
|
||||
Status::Ok => Ok(Step::Done(Ok(()))),
|
||||
Status::No | Status::Bad => Ok(Step::Done(Err(words(raw)))),
|
||||
other => Err(Error::Protocol(format!("{tag} ended with {other:?}"))),
|
||||
}
|
||||
}
|
||||
untagged => {
|
||||
each(untagged, raw);
|
||||
Ok(Step::Untagged)
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
}
|
||||
|
||||
enum Step {
|
||||
Continue,
|
||||
Untagged,
|
||||
Done(Outcome),
|
||||
}
|
||||
|
||||
/// `None` when only a literal can carry the text.
|
||||
fn quoted(text: &str) -> Result<Option<String>> {
|
||||
if text.contains('\0') {
|
||||
return Err(Error::Invalid("IMAP cannot carry a NUL byte".into()));
|
||||
}
|
||||
if !text
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii() && byte != b'\r' && byte != b'\n')
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let mut quoted = String::with_capacity(text.len() + 2);
|
||||
quoted.push('"');
|
||||
for char in text.chars() {
|
||||
if char == '"' || char == '\\' {
|
||||
quoted.push('\\');
|
||||
}
|
||||
quoted.push(char);
|
||||
}
|
||||
quoted.push('"');
|
||||
Ok(Some(quoted))
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Mail protocols, sync and local storage for ElyMail.
|
||||
|
||||
mod error;
|
||||
mod imap;
|
||||
mod net;
|
||||
mod pop;
|
||||
mod smtp;
|
||||
mod wire;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use imap::Imap;
|
||||
pub use net::{Login, Secret, Security, Server, system_tls};
|
||||
pub use pop::Pop;
|
||||
pub use smtp::Smtp;
|
||||
pub use tokio_rustls::TlsConnector;
|
||||
@@ -0,0 +1,83 @@
|
||||
use std::{future::Future, sync::Arc, time::Duration};
|
||||
|
||||
use rustls::{ClientConfig, crypto::ring, pki_types::ServerName};
|
||||
use rustls_platform_verifier::BuilderVerifierExt;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_rustls::{TlsConnector, client::TlsStream};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub(crate) type Stream = TlsStream<TcpStream>;
|
||||
|
||||
const WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Server {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub security: Security,
|
||||
}
|
||||
|
||||
/// No plaintext: every mode ends in TLS.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Security {
|
||||
Tls,
|
||||
StartTls,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Login {
|
||||
pub user: String,
|
||||
pub secret: Secret,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Secret {
|
||||
Password(String),
|
||||
/// An OAuth access token, sent as XOAUTH2.
|
||||
Token(String),
|
||||
}
|
||||
|
||||
impl Login {
|
||||
/// The SASL XOAUTH2 initial response, before base64.
|
||||
pub(crate) fn xoauth2(&self, token: &str) -> String {
|
||||
format!("user={}\x01auth=Bearer {token}\x01\x01", self.user)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies servers with the system's trust store.
|
||||
pub fn system_tls() -> Result<TlsConnector> {
|
||||
let config = ClientConfig::builder_with_provider(Arc::new(ring::default_provider()))
|
||||
.with_safe_default_protocol_versions()
|
||||
.and_then(|builder| builder.with_platform_verifier())
|
||||
.map_err(|err| Error::Tls(err.to_string()))?
|
||||
.with_no_client_auth();
|
||||
Ok(TlsConnector::from(Arc::new(config)))
|
||||
}
|
||||
|
||||
pub(crate) async fn within<T>(step: &'static str, work: impl Future<Output = T>) -> Result<T> {
|
||||
tokio::time::timeout(WAIT, work)
|
||||
.await
|
||||
.map_err(|_| Error::Timeout(step))
|
||||
}
|
||||
|
||||
pub(crate) async fn tcp(server: &Server) -> Result<TcpStream> {
|
||||
let stream = within(
|
||||
"connecting",
|
||||
TcpStream::connect((server.host.as_str(), server.port)),
|
||||
)
|
||||
.await?
|
||||
.map_err(Error::Connect)?;
|
||||
log::info!("mail: connected to {}:{}", server.host, server.port);
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
pub(crate) async fn tls(tcp: TcpStream, server: &Server, tls: &TlsConnector) -> Result<Stream> {
|
||||
let name = ServerName::try_from(server.host.clone())
|
||||
.map_err(|err| Error::Tls(format!("{}: {err}", server.host)))?;
|
||||
let stream = within("starting TLS", tls.connect(name, tcp))
|
||||
.await?
|
||||
.map_err(|err| Error::Tls(err.to_string()))?;
|
||||
log::info!("mail: TLS with {}:{}", server.host, server.port);
|
||||
Ok(stream)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use std::io;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
use crate::{
|
||||
Error, Result,
|
||||
net::{self, Login, Secret, Security, Server, Stream, within},
|
||||
};
|
||||
|
||||
/// A logged-in POP3 session (RFC 1939) over TLS.
|
||||
pub struct Pop {
|
||||
io: BufReader<Stream>,
|
||||
}
|
||||
|
||||
impl Pop {
|
||||
pub async fn connect(server: &Server, login: &Login, tls: &TlsConnector) -> Result<Self> {
|
||||
let tcp = net::tcp(server).await?;
|
||||
let stream = match server.security {
|
||||
Security::Tls => net::tls(tcp, server, tls).await?,
|
||||
Security::StartTls => {
|
||||
let mut plain = BufReader::new(tcp);
|
||||
within("waiting for the greeting", status(&mut plain)).await??;
|
||||
within("starting TLS", ask(&mut plain, "STLS")).await??;
|
||||
// Plain bytes here would pass as TLS data.
|
||||
if !plain.buffer().is_empty() {
|
||||
return Err(Error::Protocol("the server sent data after STLS".into()));
|
||||
}
|
||||
net::tls(plain.into_inner(), server, tls).await?
|
||||
}
|
||||
};
|
||||
let mut io = BufReader::new(stream);
|
||||
if server.security == Security::Tls {
|
||||
within("waiting for the greeting", status(&mut io)).await??;
|
||||
}
|
||||
within("logging in", authenticate(&mut io, login)).await??;
|
||||
log::info!("mail: POP3 login on {}", server.host);
|
||||
Ok(Self { io })
|
||||
}
|
||||
|
||||
/// Message numbers with their unique ids.
|
||||
pub async fn uids(&mut self) -> Result<Vec<(usize, String)>> {
|
||||
let list = within("listing messages", async {
|
||||
ask(&mut self.io, "UIDL").await?;
|
||||
lines(&mut self.io).await
|
||||
})
|
||||
.await??;
|
||||
list.split(|&byte| byte == b'\n')
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| {
|
||||
let line = std::str::from_utf8(line.strip_suffix(b"\r").unwrap_or(line))
|
||||
.map_err(|_| Error::Protocol("UIDL sent a line that is not text".into()))?;
|
||||
let unreadable = || Error::Protocol(format!("UIDL sent {line:?}"));
|
||||
let (number, id) = line.split_once(' ').ok_or_else(unreadable)?;
|
||||
let number = number.parse().map_err(|_| unreadable())?;
|
||||
Ok((number, id.trim_end().to_owned()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn fetch(&mut self, number: usize) -> Result<Vec<u8>> {
|
||||
within("fetching the message", async {
|
||||
ask(&mut self.io, &format!("RETR {number}")).await?;
|
||||
lines(&mut self.io).await
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
pub async fn quit(mut self) -> Result<()> {
|
||||
within("logging out", ask(&mut self.io, "QUIT")).await??;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn authenticate<S>(io: &mut BufReader<S>, login: &Login) -> Result<()>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
match &login.secret {
|
||||
Secret::Password(password) => {
|
||||
one_line(&login.user)?;
|
||||
one_line(password)?;
|
||||
ask(io, &format!("USER {}", login.user))
|
||||
.await
|
||||
.map_err(refused)?;
|
||||
ask(io, &format!("PASS {password}"))
|
||||
.await
|
||||
.map_err(refused)?;
|
||||
}
|
||||
Secret::Token(token) => {
|
||||
send(io, "AUTH XOAUTH2").await?;
|
||||
if status(io).await.map_err(refused)?.is_none() {
|
||||
return Err(Error::Protocol(
|
||||
"XOAUTH2 was accepted before the token".into(),
|
||||
));
|
||||
}
|
||||
send(io, &STANDARD.encode(login.xoauth2(token))).await?;
|
||||
if status(io).await.map_err(refused)?.is_some() {
|
||||
// An error challenge; the empty answer draws -ERR.
|
||||
send(io, "").await?;
|
||||
status(io).await.map_err(refused)?;
|
||||
return Err(Error::Protocol(
|
||||
"the token was challenged, then accepted".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Text after `+OK`, or `Some` after a `+` challenge.
|
||||
async fn status<S>(io: &mut BufReader<S>) -> Result<Option<String>>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let line = line(io).await?;
|
||||
let words = |rest: &[u8]| String::from_utf8_lossy(rest).trim().to_owned();
|
||||
if line.starts_with(b"+OK") {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix(b"-ERR") {
|
||||
return Err(Error::Server(words(rest)));
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix(b"+") {
|
||||
return Ok(Some(words(rest)));
|
||||
}
|
||||
Err(Error::Protocol(
|
||||
"the server's line was neither +OK nor -ERR".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// One command that must end in `+OK`.
|
||||
async fn ask<S>(io: &mut BufReader<S>, command: &str) -> Result<()>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
send(io, command).await?;
|
||||
match status(io).await? {
|
||||
None => Ok(()),
|
||||
Some(_) => Err(Error::Protocol(
|
||||
"the server sent a challenge to a command".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send<S>(io: &mut BufReader<S>, command: &str) -> Result<()>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let stream = io.get_mut();
|
||||
stream
|
||||
.write_all(command.as_bytes())
|
||||
.await
|
||||
.map_err(Error::Connect)?;
|
||||
stream.write_all(b"\r\n").await.map_err(Error::Connect)?;
|
||||
stream.flush().await.map_err(Error::Connect)
|
||||
}
|
||||
|
||||
async fn line<S>(io: &mut BufReader<S>) -> Result<Vec<u8>>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let mut line = Vec::new();
|
||||
io.read_until(b'\n', &mut line)
|
||||
.await
|
||||
.map_err(Error::Connect)?;
|
||||
if line.last() != Some(&b'\n') {
|
||||
return Err(Error::Connect(io::ErrorKind::UnexpectedEof.into()));
|
||||
}
|
||||
Ok(line)
|
||||
}
|
||||
|
||||
/// A multi-line answer, dot-unstuffed, line ends kept.
|
||||
async fn lines<S>(io: &mut BufReader<S>) -> Result<Vec<u8>>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let mut body = Vec::new();
|
||||
loop {
|
||||
let line = line(io).await?;
|
||||
if line == b".\r\n" || line == b".\n" {
|
||||
return Ok(body);
|
||||
}
|
||||
body.extend_from_slice(line.strip_prefix(b".").unwrap_or(&line));
|
||||
}
|
||||
}
|
||||
|
||||
/// A line break would start a second command.
|
||||
fn one_line(text: &str) -> Result<()> {
|
||||
if text.contains(['\r', '\n']) {
|
||||
return Err(Error::Invalid(
|
||||
"a user name or password holds a line break".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refused(err: Error) -> Error {
|
||||
match err {
|
||||
Error::Server(text) => Error::Rejected(text),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use mail_send::{
|
||||
Credentials, Error as SendError, SmtpClient,
|
||||
smtp::{AssertReply, message::Parameters},
|
||||
};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio_rustls::TlsConnector;
|
||||
|
||||
use crate::{
|
||||
Error, Result,
|
||||
net::{self, Login, Secret, Security, Server, Stream, within},
|
||||
};
|
||||
|
||||
/// EHLO name: an address literal, so no host name leaks.
|
||||
const HELLO: &str = "[127.0.0.1]";
|
||||
const WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// A logged-in SMTP session over TLS.
|
||||
pub struct Smtp {
|
||||
client: SmtpClient<Stream>,
|
||||
/// The server takes UTF-8 addresses (SMTPUTF8).
|
||||
utf8: bool,
|
||||
}
|
||||
|
||||
impl Smtp {
|
||||
pub async fn connect(server: &Server, login: &Login, tls: &TlsConnector) -> Result<Self> {
|
||||
let tcp = net::tcp(server).await?;
|
||||
let mut client = match server.security {
|
||||
Security::Tls => {
|
||||
let mut client = smtp(net::tls(tcp, server, tls).await?);
|
||||
greeting(&mut client).await?;
|
||||
client
|
||||
}
|
||||
Security::StartTls => {
|
||||
let mut plain = smtp(tcp);
|
||||
greeting(&mut plain).await?;
|
||||
let hello = within("greeting", plain.ehlo(HELLO))
|
||||
.await?
|
||||
.map_err(failed)?;
|
||||
if !hello.has_capability(smtp_proto::EXT_START_TLS) {
|
||||
return Err(Error::Server("the server does not offer STARTTLS".into()));
|
||||
}
|
||||
within("starting TLS", plain.start_tls(tls, &server.host))
|
||||
.await?
|
||||
.map_err(failed)?
|
||||
}
|
||||
};
|
||||
let hello = within("greeting", client.capabilities(HELLO, false))
|
||||
.await?
|
||||
.map_err(failed)?;
|
||||
match &login.secret {
|
||||
Secret::Password(password) => {
|
||||
let credentials = Credentials::Plain {
|
||||
username: login.user.clone(),
|
||||
secret: password.clone(),
|
||||
};
|
||||
within("logging in", client.authenticate(&credentials, &hello))
|
||||
.await?
|
||||
.map_err(refused)?;
|
||||
}
|
||||
Secret::Token(token) => {
|
||||
if hello.auth_mechanisms & smtp_proto::AUTH_XOAUTH2 == 0 {
|
||||
return Err(Error::Rejected("the server does not offer XOAUTH2".into()));
|
||||
}
|
||||
within("logging in", xoauth2(&mut client, &login.xoauth2(token))).await??;
|
||||
}
|
||||
}
|
||||
log::info!("mail: SMTP login on {}", server.host);
|
||||
let utf8 = hello.has_capability(smtp_proto::EXT_SMTP_UTF8);
|
||||
Ok(Self { client, utf8 })
|
||||
}
|
||||
|
||||
/// Hands one message to the server for these recipients.
|
||||
pub async fn send(&mut self, from: &str, to: &[String], message: &[u8]) -> Result<()> {
|
||||
if to.is_empty() {
|
||||
return Err(Error::Invalid("a message needs a recipient".into()));
|
||||
}
|
||||
for email in to.iter().map(String::as_str).chain([from]) {
|
||||
mailbox(email)?;
|
||||
}
|
||||
let data = data(message)?;
|
||||
let mut params = Parameters::new();
|
||||
let header = message
|
||||
.windows(4)
|
||||
.position(|end| end == b"\r\n\r\n")
|
||||
.map_or(message, |at| &message[..at]);
|
||||
if !from.is_ascii() || to.iter().any(|to| !to.is_ascii()) || !header.is_ascii() {
|
||||
if !self.utf8 {
|
||||
return Err(Error::Invalid(
|
||||
"the server does not take non-ASCII addresses or headers".into(),
|
||||
));
|
||||
}
|
||||
params.add("SMTPUTF8");
|
||||
}
|
||||
let sent = within("sending", async {
|
||||
self.client.mail_from(from, ¶ms).await?;
|
||||
for to in to {
|
||||
self.client.rcpt_to(to, &Parameters::new()).await?;
|
||||
}
|
||||
self.client.cmd(b"DATA\r\n").await?.assert_code(354)?;
|
||||
self.client.stream.write_all(&data).await?;
|
||||
self.client.stream.flush().await?;
|
||||
self.client.read().await?.assert_positive_completion()
|
||||
})
|
||||
.await?;
|
||||
if let Err(SendError::UnexpectedReply(reply)) = sent {
|
||||
// A refusal leaves the transaction open.
|
||||
let words = format!("{} {}", reply.code(), reply.message());
|
||||
return Err(match within("resetting", self.client.rset()).await? {
|
||||
Ok(()) => Error::Server(words),
|
||||
Err(err) => Error::Server(format!("{words}; RSET failed: {}", failed(err))),
|
||||
});
|
||||
}
|
||||
sent.map_err(failed)?;
|
||||
log::info!("mail: SMTP accepted a message for {} recipients", to.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn quit(self) -> Result<()> {
|
||||
within("logging out", self.client.quit())
|
||||
.await?
|
||||
.map_err(failed)
|
||||
}
|
||||
}
|
||||
|
||||
fn smtp<T: tokio::io::AsyncRead + tokio::io::AsyncWrite>(stream: T) -> SmtpClient<T> {
|
||||
SmtpClient {
|
||||
stream,
|
||||
timeout: WAIT,
|
||||
}
|
||||
}
|
||||
|
||||
async fn greeting<T>(client: &mut SmtpClient<T>) -> Result<()>
|
||||
where
|
||||
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let reply = within("waiting for the greeting", client.read())
|
||||
.await?
|
||||
.map_err(failed)?;
|
||||
reply.assert_positive_completion().map_err(failed)
|
||||
}
|
||||
|
||||
/// Gmail's exchange: an error challenge draws an empty line.
|
||||
async fn xoauth2<T>(client: &mut SmtpClient<T>, initial: &str) -> Result<()>
|
||||
where
|
||||
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
let command = format!("AUTH XOAUTH2 {}\r\n", STANDARD.encode(initial));
|
||||
let mut reply = client.cmd(command).await.map_err(failed)?;
|
||||
if reply.code() == 334 {
|
||||
reply = client.cmd("\r\n").await.map_err(failed)?;
|
||||
}
|
||||
match reply.code() {
|
||||
235 => Ok(()),
|
||||
code => Err(Error::Rejected(format!("{code} {}", reply.message()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 5321 §4.5.2: a leading dot doubles.
|
||||
fn data(message: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(message.len() + 8);
|
||||
let mut start = true;
|
||||
for (at, &byte) in message.iter().enumerate() {
|
||||
let bare = match byte {
|
||||
b'\r' => message.get(at + 1) != Some(&b'\n'),
|
||||
b'\n' => at == 0 || message[at - 1] != b'\r',
|
||||
_ => false,
|
||||
};
|
||||
if bare {
|
||||
return Err(Error::Invalid("the message has a bare CR or LF".into()));
|
||||
}
|
||||
if start && byte == b'.' {
|
||||
out.push(b'.');
|
||||
}
|
||||
out.push(byte);
|
||||
start = byte == b'\n';
|
||||
}
|
||||
if !out.is_empty() && !out.ends_with(b"\r\n") {
|
||||
out.extend_from_slice(b"\r\n");
|
||||
}
|
||||
out.extend_from_slice(b".\r\n");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// RFC 5321 §4.1.2 Mailbox, with RFC 6531 UTF-8.
|
||||
fn mailbox(email: &str) -> Result<()> {
|
||||
let readable = email.rsplit_once('@').is_some_and(|(local, domain)| {
|
||||
let local = match local
|
||||
.strip_prefix('"')
|
||||
.and_then(|rest| rest.strip_suffix('"'))
|
||||
{
|
||||
Some(inner) => quoted(inner),
|
||||
None => dotted(local),
|
||||
};
|
||||
local && domain_ok(domain)
|
||||
});
|
||||
if !readable {
|
||||
return Err(Error::Invalid(format!("not a mail address: {email:?}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn quoted(inner: &str) -> bool {
|
||||
let mut chars = inner.chars();
|
||||
while let Some(char) = chars.next() {
|
||||
let fine = match char {
|
||||
'\\' => chars.next().is_some_and(|next| matches!(next, ' '..='~')),
|
||||
'"' => false,
|
||||
' '..='~' => true,
|
||||
_ => !char.is_ascii(),
|
||||
};
|
||||
if !fine {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn dotted(local: &str) -> bool {
|
||||
let atext = |char: char| {
|
||||
char.is_ascii_alphanumeric() || "!#$%&'*+-/=?^_`{|}~".contains(char) || !char.is_ascii()
|
||||
};
|
||||
local
|
||||
.split('.')
|
||||
.all(|atom| !atom.is_empty() && atom.chars().all(atext))
|
||||
}
|
||||
|
||||
fn domain_ok(domain: &str) -> bool {
|
||||
if let Some(literal) = domain
|
||||
.strip_prefix('[')
|
||||
.and_then(|rest| rest.strip_suffix(']'))
|
||||
{
|
||||
return !literal.is_empty()
|
||||
&& literal
|
||||
.bytes()
|
||||
.all(|byte| matches!(byte, 33..=90 | 94..=126));
|
||||
}
|
||||
domain.split('.').all(|label| {
|
||||
!label.is_empty()
|
||||
&& !label.starts_with('-')
|
||||
&& !label.ends_with('-')
|
||||
&& label
|
||||
.chars()
|
||||
.all(|char| char.is_ascii_alphanumeric() || char == '-' || !char.is_ascii())
|
||||
})
|
||||
}
|
||||
|
||||
fn refused(err: SendError) -> Error {
|
||||
match err {
|
||||
SendError::AuthenticationFailed(reply) => {
|
||||
Error::Rejected(format!("{} {}", reply.code(), reply.message()))
|
||||
}
|
||||
SendError::UnsupportedAuthMechanism => {
|
||||
Error::Rejected("the server offers no login method for these credentials".into())
|
||||
}
|
||||
other => failed(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn failed(err: SendError) -> Error {
|
||||
match err {
|
||||
SendError::Io(err) => Error::Connect(err),
|
||||
SendError::Tls(err) => Error::Tls(err.to_string()),
|
||||
SendError::Timeout => Error::Timeout("talking to the server"),
|
||||
SendError::UnexpectedReply(reply) => {
|
||||
Error::Server(format!("{} {}", reply.code(), reply.message()))
|
||||
}
|
||||
other => Error::Protocol(other.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use std::io;
|
||||
|
||||
use imap_proto::{Response, Status};
|
||||
use nom::{Err as NomErr, Needed};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
const CHUNK: usize = 16 * 1024;
|
||||
|
||||
/// IMAP responses off a byte stream; keeps the last BYE.
|
||||
pub(crate) struct Wire<S> {
|
||||
io: S,
|
||||
buf: Vec<u8>,
|
||||
/// Bytes the next parse needs before it can finish.
|
||||
needs: usize,
|
||||
bye: Option<String>,
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + AsyncWrite + Unpin> Wire<S> {
|
||||
pub fn new(io: S) -> Self {
|
||||
Self {
|
||||
io,
|
||||
buf: Vec::new(),
|
||||
needs: 0,
|
||||
bye: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hands the next response and its raw bytes to `read`.
|
||||
pub async fn next<R>(&mut self, mut read: impl FnMut(&Response, &[u8]) -> R) -> Result<R> {
|
||||
loop {
|
||||
if self.buf.len() >= self.needs && !self.buf.is_empty() {
|
||||
let parsed = match Response::parse(&self.buf) {
|
||||
Ok((rest, response)) => {
|
||||
let used = self.buf.len() - rest.len();
|
||||
let raw = &self.buf[..used];
|
||||
if let Response::Data {
|
||||
status: Status::Bye,
|
||||
..
|
||||
} = response
|
||||
{
|
||||
self.bye = Some(words(raw));
|
||||
}
|
||||
Some((used, read(&response, raw)))
|
||||
}
|
||||
Err(NomErr::Incomplete(Needed::Size(more))) => {
|
||||
self.needs = self.buf.len() + more.get();
|
||||
None
|
||||
}
|
||||
Err(NomErr::Incomplete(Needed::Unknown)) => {
|
||||
self.needs = self.buf.len() + 1;
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(Error::Protocol(
|
||||
"a reply from the server could not be read".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Some((used, value)) = parsed {
|
||||
self.buf.drain(..used);
|
||||
self.needs = 0;
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
self.buf.reserve(CHUNK);
|
||||
match self.io.read_buf(&mut self.buf).await {
|
||||
Ok(0) => return Err(self.closed(io::ErrorKind::UnexpectedEof.into())),
|
||||
Ok(_) => {}
|
||||
Err(err) => return Err(self.closed(err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write(&mut self, bytes: &[u8]) -> Result<()> {
|
||||
let sent = async {
|
||||
self.io.write_all(bytes).await?;
|
||||
self.io.flush().await
|
||||
};
|
||||
sent.await.map_err(|err| self.closed(err))
|
||||
}
|
||||
|
||||
/// The stream for TLS; bytes already read would be plaintext.
|
||||
pub fn into_inner(self) -> Result<S> {
|
||||
if !self.buf.is_empty() {
|
||||
return Err(Error::Protocol(
|
||||
"the server sent data before TLS began".into(),
|
||||
));
|
||||
}
|
||||
Ok(self.io)
|
||||
}
|
||||
|
||||
/// After BYE, a dropped line is the server's words.
|
||||
fn closed(&self, err: io::Error) -> Error {
|
||||
match &self.bye {
|
||||
Some(words) => Error::Server(words.clone()),
|
||||
None => Error::Connect(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The line after its tag, as the server wrote it.
|
||||
pub(crate) fn words(raw: &[u8]) -> String {
|
||||
let line = raw.strip_suffix(b"\r\n").unwrap_or(raw);
|
||||
let text = line.splitn(2, |&byte| byte == b' ').nth(1).unwrap_or(line);
|
||||
String::from_utf8_lossy(text).into_owned()
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! A scripted mail server over real TLS, for protocol tests.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use ely_mail_engine::{Login, Secret, Security, Server, TlsConnector};
|
||||
use rcgen::{CertificateParams, KeyPair};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader},
|
||||
net::TcpListener,
|
||||
task::JoinHandle,
|
||||
};
|
||||
use tokio_rustls::{
|
||||
TlsAcceptor,
|
||||
rustls::{
|
||||
ClientConfig, RootCertStore, ServerConfig,
|
||||
crypto::ring,
|
||||
pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer},
|
||||
},
|
||||
};
|
||||
|
||||
pub enum Step {
|
||||
/// CRLF for `\n`; `{tag}` is the client's last tag.
|
||||
Send(String),
|
||||
/// One line; a leading `{tag} ` takes the client's tag.
|
||||
Expect(String),
|
||||
/// SMTP DATA lines before the lone dot.
|
||||
Data(String),
|
||||
StartTls,
|
||||
}
|
||||
|
||||
pub fn send(text: &str) -> Step {
|
||||
Step::Send(text.to_owned())
|
||||
}
|
||||
|
||||
pub fn expect(text: &str) -> Step {
|
||||
Step::Expect(text.to_owned())
|
||||
}
|
||||
|
||||
trait Io: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
impl<T: AsyncRead + AsyncWrite + Unpin + Send> Io for T {}
|
||||
|
||||
pub struct Certs {
|
||||
acceptor: TlsAcceptor,
|
||||
/// Trusts only this test certificate.
|
||||
pub trusted: TlsConnector,
|
||||
}
|
||||
|
||||
pub fn certs() -> Certs {
|
||||
let key = KeyPair::generate().unwrap();
|
||||
let cert = CertificateParams::new(vec!["127.0.0.1".into()])
|
||||
.unwrap()
|
||||
.self_signed(&key)
|
||||
.unwrap();
|
||||
let der = CertificateDer::from(cert.der().to_vec());
|
||||
let provider = Arc::new(ring::default_provider());
|
||||
let server = ServerConfig::builder_with_provider(provider.clone())
|
||||
.with_safe_default_protocol_versions()
|
||||
.unwrap()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(
|
||||
vec![der.clone()],
|
||||
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key.serialize_der())),
|
||||
)
|
||||
.unwrap();
|
||||
let mut roots = RootCertStore::empty();
|
||||
roots.add(der).unwrap();
|
||||
let client = ClientConfig::builder_with_provider(provider)
|
||||
.with_safe_default_protocol_versions()
|
||||
.unwrap()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
Certs {
|
||||
acceptor: TlsAcceptor::from(Arc::new(server)),
|
||||
trusted: TlsConnector::from(Arc::new(client)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn login(secret: Secret) -> Login {
|
||||
Login {
|
||||
user: "ann@example.com".into(),
|
||||
secret,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves one connection; the task fails on the first mismatch.
|
||||
pub async fn serve(
|
||||
certs: &Certs,
|
||||
security: Security,
|
||||
steps: Vec<Step>,
|
||||
) -> (Server, JoinHandle<Result<(), String>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let acceptor = certs.acceptor.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let (tcp, _) = listener.accept().await.map_err(|err| err.to_string())?;
|
||||
let io: Box<dyn Io> = match security {
|
||||
Security::Tls => Box::new(acceptor.accept(tcp).await.map_err(|e| e.to_string())?),
|
||||
Security::StartTls => Box::new(tcp),
|
||||
};
|
||||
run(io, &acceptor, steps).await
|
||||
});
|
||||
let server = Server {
|
||||
host: "127.0.0.1".into(),
|
||||
port,
|
||||
security,
|
||||
};
|
||||
(server, task)
|
||||
}
|
||||
|
||||
async fn run(io: Box<dyn Io>, acceptor: &TlsAcceptor, steps: Vec<Step>) -> Result<(), String> {
|
||||
let mut io = BufReader::new(io);
|
||||
let mut tag = String::new();
|
||||
for step in steps {
|
||||
match step {
|
||||
Step::Send(text) => {
|
||||
let text = text.replace("{tag}", &tag).replace('\n', "\r\n");
|
||||
io.write_all(text.as_bytes())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
io.flush().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Step::Expect(want) => {
|
||||
let got = line(&mut io).await?;
|
||||
let got = match want.strip_prefix("{tag} ") {
|
||||
Some(_) => {
|
||||
let (first, rest) = got.split_once(' ').unwrap_or((&got, ""));
|
||||
tag = first.to_owned();
|
||||
format!("{{tag}} {rest}")
|
||||
}
|
||||
None => got,
|
||||
};
|
||||
if got != want {
|
||||
return Err(format!("expected {want:?}, got {got:?}"));
|
||||
}
|
||||
}
|
||||
Step::Data(want) => {
|
||||
let mut lines = Vec::new();
|
||||
loop {
|
||||
let got = line(&mut io).await?;
|
||||
if got == "." {
|
||||
break;
|
||||
}
|
||||
lines.push(got);
|
||||
}
|
||||
let got = lines.join("\n");
|
||||
if got != want {
|
||||
return Err(format!("expected data {want:?}, got {got:?}"));
|
||||
}
|
||||
}
|
||||
Step::StartTls => {
|
||||
let tcp = io.into_inner();
|
||||
let tls = acceptor.accept(tcp).await.map_err(|e| e.to_string())?;
|
||||
io = BufReader::new(Box::new(tls));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn line(io: &mut BufReader<Box<dyn Io>>) -> Result<String, String> {
|
||||
let mut line = String::new();
|
||||
io.read_line(&mut line).await.map_err(|e| e.to_string())?;
|
||||
match line.strip_suffix("\r\n") {
|
||||
Some(line) => Ok(line.to_owned()),
|
||||
None => Err(format!("client sent {line:?} without CRLF")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
mod fake;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use ely_mail_engine::{Error, Imap, Secret, Security, system_tls};
|
||||
use fake::{certs, expect, login, send, serve};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
const TOKEN_LINE: &str = "user=ann@example.com\x01auth=Bearer tok-1\x01\x01";
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_password_login_lists_and_fetches() {
|
||||
let certs = certs();
|
||||
let lines = "Subject: Hi\n\nHello\n";
|
||||
let body = lines.replace('\n', "\r\n");
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} LOGIN \"ann@example.com\" \"pw 1\""),
|
||||
send("{tag} OK done\n"),
|
||||
expect("{tag} LIST \"\" *"),
|
||||
send("* LIST (\\HasNoChildren) \"/\" INBOX\n"),
|
||||
send("* LIST (\\HasNoChildren \\Sent) \"/\" \"Sent Mail\"\n"),
|
||||
send("{tag} OK listed\n"),
|
||||
expect("{tag} EXAMINE \"INBOX\""),
|
||||
send("* 1 EXISTS\n* OK [UIDVALIDITY 7] v\n{tag} OK [READ-ONLY] in\n"),
|
||||
expect("{tag} UID FETCH 42 BODY.PEEK[]"),
|
||||
send(&format!(
|
||||
"* 1 FETCH (UID 42 BODY[] {{{}}}\n{lines})\n{{tag}} OK fetched\n",
|
||||
body.len()
|
||||
)),
|
||||
expect("{tag} LOGOUT"),
|
||||
send("* BYE\n{tag} OK bye\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut imap = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw 1".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(imap.mailboxes().await.unwrap(), ["INBOX", "Sent Mail"]);
|
||||
let fetched = imap.fetch("INBOX", 42).await.unwrap().unwrap();
|
||||
assert_eq!(String::from_utf8(fetched).unwrap(), body);
|
||||
imap.logout().await.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn starttls_then_xoauth2() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::StartTls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} STARTTLS"),
|
||||
send("{tag} OK go\n"),
|
||||
fake::Step::StartTls,
|
||||
expect("{tag} AUTHENTICATE XOAUTH2"),
|
||||
send("+ \n"),
|
||||
expect(&STANDARD.encode(TOKEN_LINE)),
|
||||
send("{tag} OK welcome\n"),
|
||||
expect("{tag} LOGOUT"),
|
||||
send("* BYE\n{tag} OK bye\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let imap = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Token("tok-1".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
imap.logout().await.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_password_keeps_the_server_words() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} LOGIN \"ann@example.com\" \"bad\""),
|
||||
send("{tag} NO [AUTHENTICATIONFAILED] Invalid credentials\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let err = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("bad".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Rejected(text) if text.contains("Invalid credentials")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_token_answers_the_error_challenge_empty() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} AUTHENTICATE XOAUTH2"),
|
||||
send("+ \n"),
|
||||
expect(&STANDARD.encode(TOKEN_LINE)),
|
||||
send(&format!("+ {}\n", STANDARD.encode(r#"{"status":"401"}"#))),
|
||||
expect(""),
|
||||
send("{tag} NO [AUTHENTICATIONFAILED] Invalid token\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let err = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Token("tok-1".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Rejected(text) if text.contains("Invalid token")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_missing_uid_is_none() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} LOGIN \"ann@example.com\" \"pw\""),
|
||||
send("{tag} OK done\n"),
|
||||
expect("{tag} EXAMINE \"INBOX\""),
|
||||
send("{tag} OK in\n"),
|
||||
expect("{tag} UID FETCH 9 BODY.PEEK[]"),
|
||||
send("{tag} OK nothing\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut imap = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(imap.fetch("INBOX", 9).await.unwrap(), None);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unreadable_reply_is_a_protocol_error() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} LOGIN \"ann@example.com\" \"pw\""),
|
||||
send("{tag} OK done\n"),
|
||||
expect("{tag} LIST \"\" *"),
|
||||
send("* LIST (((\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut imap = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = imap.mailboxes().await.unwrap_err();
|
||||
assert!(matches!(err, Error::Protocol(_)), "{err}");
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_system_store_refuses_an_unknown_certificate() {
|
||||
let certs = certs();
|
||||
let (server, _script) = serve(&certs, Security::Tls, vec![send("* OK ready\n")]).await;
|
||||
let err = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&system_tls().unwrap(),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(matches!(err, Error::Tls(_)), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_greeting_bye_keeps_the_server_words() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![send("* BYE [UNAVAILABLE] Maintenance in progress\n")],
|
||||
)
|
||||
.await;
|
||||
let err = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Server(text) if text.contains("Maintenance in progress")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
/// Greeting and LOGIN "pw", then `steps`.
|
||||
async fn logged_in(steps: Vec<fake::Step>) -> (Imap, JoinHandle<Result<(), String>>) {
|
||||
let certs = certs();
|
||||
let mut script = vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} LOGIN \"ann@example.com\" \"pw\""),
|
||||
send("{tag} OK done\n"),
|
||||
];
|
||||
script.extend(steps);
|
||||
let (server, script) = serve(&certs, Security::Tls, script).await;
|
||||
let imap = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(imap, script)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listed_names_lose_quote_escapes_and_open_as_listed() {
|
||||
let (mut imap, script) = logged_in(vec![
|
||||
expect("{tag} LIST \"\" *"),
|
||||
send("* LIST () \"/\" \"a\\\"b\"\n"),
|
||||
send("* LIST () \"/\" \"c\\\\d\"\n"),
|
||||
send("* LIST () \"/\" {3}\ne\\\"\n"),
|
||||
send("{tag} OK listed\n"),
|
||||
expect("{tag} EXAMINE \"a\\\"b\""),
|
||||
send("{tag} OK in\n"),
|
||||
expect("{tag} UID FETCH 9 BODY.PEEK[]"),
|
||||
send("{tag} OK nothing\n"),
|
||||
])
|
||||
.await;
|
||||
let names = imap.mailboxes().await.unwrap();
|
||||
assert_eq!(names, ["a\"b", "c\\d", "e\\\""]);
|
||||
assert_eq!(imap.fetch(&names[0], 9).await.unwrap(), None);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_list_keeps_the_server_words() {
|
||||
let (mut imap, script) = logged_in(vec![
|
||||
expect("{tag} LIST \"\" *"),
|
||||
send("* LIST () \"/\" INBOX\n"),
|
||||
send("{tag} NO [UNAVAILABLE] Mailbox listing temporarily unavailable\n"),
|
||||
])
|
||||
.await;
|
||||
let err = imap.mailboxes().await.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Server(text) if text.contains("listing temporarily unavailable")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_fetch_is_not_a_missing_uid() {
|
||||
let (mut imap, script) = logged_in(vec![
|
||||
expect("{tag} EXAMINE \"INBOX\""),
|
||||
send("{tag} OK in\n"),
|
||||
expect("{tag} UID FETCH 9 BODY.PEEK[]"),
|
||||
send("{tag} NO [UNAVAILABLE] Message store temporarily unavailable\n"),
|
||||
])
|
||||
.await;
|
||||
let err = imap.fetch("INBOX", 9).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Server(text) if text.contains("store temporarily unavailable")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_bye_after_login_keeps_the_server_words() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} LOGIN \"ann@example.com\" \"pw\""),
|
||||
send("* BYE [UNAVAILABLE] Too many simultaneous connections\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let err = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Server(text) if text.contains("Too many simultaneous connections")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
|
||||
let (mut imap, script) = logged_in(vec![
|
||||
expect("{tag} LIST \"\" *"),
|
||||
send("* BYE Shutting down\n"),
|
||||
])
|
||||
.await;
|
||||
let err = imap.mailboxes().await.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Server(text) if text.contains("Shutting down")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_password_beyond_ascii_goes_as_a_literal() {
|
||||
let certs = certs();
|
||||
let password = "pässwört";
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect(&format!(
|
||||
"{{tag}} LOGIN \"ann@example.com\" {{{}}}",
|
||||
password.len()
|
||||
)),
|
||||
send("+ go on\n"),
|
||||
expect(password),
|
||||
send("{tag} OK done\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password(password.into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bytes_before_tls_are_refused() {
|
||||
let certs = certs();
|
||||
let (server, _script) = serve(
|
||||
&certs,
|
||||
Security::StartTls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect("{tag} STARTTLS"),
|
||||
send("{tag} OK go\n* OK injected\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let err = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Protocol(text) if text.contains("before TLS")),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
mod fake;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use ely_mail_engine::{Error, Pop, Secret, Security};
|
||||
use fake::{Step, certs, expect, login, send, serve};
|
||||
|
||||
const TOKEN_LINE: &str = "user=ann@example.com\x01auth=Bearer tok-1\x01\x01";
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_password_login_lists_uids_and_fetches() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("+OK ready\n"),
|
||||
expect("USER ann@example.com"),
|
||||
send("+OK\n"),
|
||||
expect("PASS pw 1"),
|
||||
send("+OK in\n"),
|
||||
expect("UIDL"),
|
||||
send("+OK\n1 aa1\n2 bb2\n.\n"),
|
||||
expect("RETR 2"),
|
||||
send("+OK 27 octets\nSubject: Hi\n\nHello\n..dot\n...\n.\n"),
|
||||
expect("QUIT"),
|
||||
send("+OK bye\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut pop = Pop::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw 1".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
pop.uids().await.unwrap(),
|
||||
[(1, "aa1".to_owned()), (2, "bb2".to_owned())]
|
||||
);
|
||||
let message = String::from_utf8(pop.fetch(2).await.unwrap()).unwrap();
|
||||
assert_eq!(message, "Subject: Hi\r\n\r\nHello\r\n.dot\r\n..\r\n");
|
||||
pop.quit().await.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stls_then_xoauth2_sends_the_token_after_the_plus() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::StartTls,
|
||||
vec![
|
||||
send("+OK ready\n"),
|
||||
expect("STLS"),
|
||||
send("+OK begin TLS\n"),
|
||||
Step::StartTls,
|
||||
expect("AUTH XOAUTH2"),
|
||||
send("+ \n"),
|
||||
expect(&STANDARD.encode(TOKEN_LINE)),
|
||||
send("+OK in\n"),
|
||||
expect("QUIT"),
|
||||
send("+OK bye\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let pop = Pop::connect(
|
||||
&server,
|
||||
&login(Secret::Token("tok-1".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
pop.quit().await.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_token_answers_the_error_challenge_empty() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("+OK ready\n"),
|
||||
expect("AUTH XOAUTH2"),
|
||||
send("+ \n"),
|
||||
expect(&STANDARD.encode(TOKEN_LINE)),
|
||||
send(&format!("+ {}\n", STANDARD.encode(r#"{"status":"401"}"#))),
|
||||
expect(""),
|
||||
send("-ERR Authentication failure: unknown user name or bad password.\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let err = Pop::connect(
|
||||
&server,
|
||||
&login(Secret::Token("tok-1".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Rejected(text) if text.contains("bad password")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_password_keeps_the_server_words() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("+OK ready\n"),
|
||||
expect("USER ann@example.com"),
|
||||
send("+OK\n"),
|
||||
expect("PASS bad"),
|
||||
send("-ERR [AUTH] Username and password not accepted.\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let err = Pop::connect(
|
||||
&server,
|
||||
&login(Secret::Password("bad".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Rejected(text) if text.contains("not accepted")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_greeting_err_keeps_the_server_words() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(&certs, Security::Tls, vec![send("-ERR maildrop busy\n")]).await;
|
||||
let err = Pop::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Server(text) if text == "maildrop busy"),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_connection_lost_before_the_pass_reply_is_an_error() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("+OK ready\n"),
|
||||
expect("USER ann@example.com"),
|
||||
send("+OK\n"),
|
||||
expect("PASS pw"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let err = Pop::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(matches!(&err, Error::Connect(_)), "{err}");
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plain_bytes_after_stls_are_refused() {
|
||||
let certs = certs();
|
||||
let (server, _script) = serve(
|
||||
&certs,
|
||||
Security::StartTls,
|
||||
vec![
|
||||
send("+OK ready\n"),
|
||||
expect("STLS"),
|
||||
send("+OK begin TLS\nUIDL\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let err = Pop::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Protocol(text) if text.contains("after STLS")),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_line_break_in_the_login_never_leaves() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(&certs, Security::Tls, vec![send("+OK ready\n")]).await;
|
||||
let err = Pop::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw\r\nDELE 1".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(matches!(&err, Error::Invalid(_)), "{err}");
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! The real logger at trace, around real protocol traffic.
|
||||
mod fake;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use ely_mail_engine::{Imap, Pop, Secret, Security, Smtp};
|
||||
use ely_platform::AppId;
|
||||
use fake::{Step, certs, expect, login, send, serve};
|
||||
|
||||
const PASSWORD: &str = "PRIVATE_PASSWORD";
|
||||
const TOKEN: &str = "PRIVATE_TOKEN";
|
||||
const BODY: &str = "PRIVATE_BODY";
|
||||
|
||||
#[tokio::test]
|
||||
async fn trace_logs_keep_no_password_token_or_mail() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// The only test here; no thread reads env.
|
||||
unsafe { std::env::set_var("ELY_LOG", "trace") };
|
||||
ely_platform::logging::init_in(AppId::Mail, dir.path()).unwrap();
|
||||
let certs = certs();
|
||||
let message = format!("Subject: x\n\n{BODY}\n");
|
||||
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("* OK ready\n"),
|
||||
expect(&format!("{{tag}} LOGIN \"ann@example.com\" \"{PASSWORD}\"")),
|
||||
send("{tag} OK in\n"),
|
||||
expect("{tag} EXAMINE \"INBOX\""),
|
||||
send("{tag} OK done\n"),
|
||||
expect("{tag} UID FETCH 7 BODY.PEEK[]"),
|
||||
send(&format!(
|
||||
"* 1 FETCH (UID 7 BODY[] {{{}}}\n{message})\n{{tag}} OK fetched\n",
|
||||
message.replace('\n', "\r\n").len()
|
||||
)),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut imap = Imap::connect(
|
||||
&server,
|
||||
&login(Secret::Password(PASSWORD.into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(imap.fetch("INBOX", 7).await.unwrap().is_some());
|
||||
script.await.unwrap().unwrap();
|
||||
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("+OK ready\n"),
|
||||
expect("USER ann@example.com"),
|
||||
send("+OK\n"),
|
||||
expect(&format!("PASS {PASSWORD}")),
|
||||
send("+OK in\n"),
|
||||
expect("RETR 1"),
|
||||
send(&format!("+OK\n{message}.\n")),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut pop = Pop::connect(
|
||||
&server,
|
||||
&login(Secret::Password(PASSWORD.into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
pop.fetch(1).await.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
|
||||
let initial = STANDARD.encode(format!(
|
||||
"user=ann@example.com\x01auth=Bearer {TOKEN}\x01\x01"
|
||||
));
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("220 mx ready\n"),
|
||||
expect("EHLO [127.0.0.1]"),
|
||||
send("250-mx\n250 AUTH XOAUTH2\n"),
|
||||
expect(&format!("AUTH XOAUTH2 {initial}")),
|
||||
send("235 ok\n"),
|
||||
expect("MAIL FROM:<ann@example.com>"),
|
||||
send("250 ok\n"),
|
||||
expect("RCPT TO:<bo@example.com>"),
|
||||
send("250 ok\n"),
|
||||
expect("DATA"),
|
||||
send("354 go\n"),
|
||||
Step::Data(format!("Subject: x\n\n{BODY}")),
|
||||
send("250 queued\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut smtp = Smtp::connect(&server, &login(Secret::Token(TOKEN.into())), &certs.trusted)
|
||||
.await
|
||||
.unwrap();
|
||||
smtp.send(
|
||||
"ann@example.com",
|
||||
&["bo@example.com".to_owned()],
|
||||
message.replace('\n', "\r\n").as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
|
||||
log::logger().flush();
|
||||
let logged = std::fs::read_to_string(dir.path().join("ely.log")).unwrap();
|
||||
for line in ["IMAP login", "POP3 login", "SMTP login"] {
|
||||
assert!(logged.contains(line), "missing {line:?} in\n{logged}");
|
||||
}
|
||||
for secret in [PASSWORD, TOKEN, BODY, &initial] {
|
||||
assert!(
|
||||
!logged.contains(secret),
|
||||
"{secret} reached the log:\n{logged}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
mod fake;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use ely_mail_engine::{Error, Secret, Security, Smtp};
|
||||
use fake::{Step, certs, expect, login, send, serve};
|
||||
|
||||
#[tokio::test]
|
||||
async fn starttls_password_login_sends_one_message() {
|
||||
let certs = certs();
|
||||
let plain = STANDARD.encode("\0ann@example.com\0pw 1");
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::StartTls,
|
||||
vec![
|
||||
send("220 mx ready\n"),
|
||||
expect("EHLO [127.0.0.1]"),
|
||||
send("250-mx\n250 STARTTLS\n"),
|
||||
expect("STARTTLS"),
|
||||
send("220 go\n"),
|
||||
Step::StartTls,
|
||||
expect("EHLO [127.0.0.1]"),
|
||||
send("250-mx\n250 AUTH PLAIN\n"),
|
||||
expect(&format!("AUTH PLAIN {plain}")),
|
||||
send("235 ok\n"),
|
||||
expect("MAIL FROM:<ann@example.com>"),
|
||||
send("250 ok\n"),
|
||||
expect("RCPT TO:<bo@example.com>"),
|
||||
send("250 ok\n"),
|
||||
expect("RCPT TO:<cy@example.com>"),
|
||||
send("250 ok\n"),
|
||||
expect("DATA"),
|
||||
send("354 go\n"),
|
||||
Step::Data("Subject: Hi\n\nHello\n..dot".into()),
|
||||
send("250 queued as 7\n"),
|
||||
expect("QUIT"),
|
||||
send("221 bye\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut smtp = Smtp::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw 1".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let to = ["bo@example.com".to_owned(), "cy@example.com".to_owned()];
|
||||
smtp.send(
|
||||
"ann@example.com",
|
||||
&to,
|
||||
b"Subject: Hi\r\n\r\nHello\r\n.dot\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
smtp.quit().await.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn implicit_tls_xoauth2() {
|
||||
let certs = certs();
|
||||
let token = STANDARD.encode("user=ann@example.com\x01auth=Bearer tok-1\x01\x01");
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("220 mx ready\n"),
|
||||
expect("EHLO [127.0.0.1]"),
|
||||
send("250-mx\n250 AUTH XOAUTH2\n"),
|
||||
expect(&format!("AUTH XOAUTH2 {token}")),
|
||||
send("235 ok\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
Smtp::connect(
|
||||
&server,
|
||||
&login(Secret::Token("tok-1".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_password_keeps_the_server_words() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("220 mx ready\n"),
|
||||
expect("EHLO [127.0.0.1]"),
|
||||
send("250-mx\n250 AUTH PLAIN\n"),
|
||||
expect(&format!(
|
||||
"AUTH PLAIN {}",
|
||||
STANDARD.encode("\0ann@example.com\0bad")
|
||||
)),
|
||||
send("535 5.7.8 Username and Password not accepted\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let err = Smtp::connect(
|
||||
&server,
|
||||
&login(Secret::Password("bad".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Rejected(text) if text.contains("535") && text.contains("not accepted")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_server_without_starttls_is_refused() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::StartTls,
|
||||
vec![
|
||||
send("220 mx ready\n"),
|
||||
expect("EHLO [127.0.0.1]"),
|
||||
send("250-mx\n250 AUTH PLAIN\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let err = Smtp::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Server(text) if text.contains("STARTTLS")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_recipient_keeps_the_server_words() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("220 mx ready\n"),
|
||||
expect("EHLO [127.0.0.1]"),
|
||||
send("250-mx\n250 AUTH PLAIN\n"),
|
||||
expect(&format!(
|
||||
"AUTH PLAIN {}",
|
||||
STANDARD.encode("\0ann@example.com\0pw")
|
||||
)),
|
||||
send("235 ok\n"),
|
||||
expect("MAIL FROM:<ann@example.com>"),
|
||||
send("250 ok\n"),
|
||||
expect("RCPT TO:<nobody@example.com>"),
|
||||
send("550 5.1.1 No such user\n"),
|
||||
expect("RSET"),
|
||||
send("250 ok\n"),
|
||||
expect("MAIL FROM:<ann@example.com>"),
|
||||
send("250 ok\n"),
|
||||
expect("RCPT TO:<bo@example.com>"),
|
||||
send("250 ok\n"),
|
||||
expect("DATA"),
|
||||
send("354 go\n"),
|
||||
Step::Data("x".into()),
|
||||
send("250 queued\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut smtp = Smtp::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = smtp
|
||||
.send(
|
||||
"ann@example.com",
|
||||
&["nobody@example.com".to_owned()],
|
||||
b"x\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Server(text) if text.contains("550") && text.contains("No such user")),
|
||||
"{err}"
|
||||
);
|
||||
smtp.send("ann@example.com", &["bo@example.com".to_owned()], b"x\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_token_answers_the_error_challenge_empty() {
|
||||
let certs = certs();
|
||||
let token = STANDARD.encode("user=ann@example.com\x01auth=Bearer tok-1\x01\x01");
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("220 mx ready\n"),
|
||||
expect("EHLO [127.0.0.1]"),
|
||||
send("250-mx\n250 AUTH XOAUTH2\n"),
|
||||
expect(&format!("AUTH XOAUTH2 {token}")),
|
||||
send(&format!("334 {}\n", STANDARD.encode(r#"{"status":"400"}"#))),
|
||||
expect(""),
|
||||
send("535-5.7.8 Username and Password not accepted\n535 5.7.8 Learn more\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let err = Smtp::connect(
|
||||
&server,
|
||||
&login(Secret::Token("tok-1".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&err, Error::Rejected(text) if text.contains("535") && text.contains("not accepted")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_envelope_with_a_line_break_or_bracket_never_leaves() {
|
||||
let certs = certs();
|
||||
let (server, script) = serve(
|
||||
&certs,
|
||||
Security::Tls,
|
||||
vec![
|
||||
send("220 mx ready\n"),
|
||||
expect("EHLO [127.0.0.1]"),
|
||||
send("250-mx\n250 AUTH PLAIN\n"),
|
||||
expect(&format!(
|
||||
"AUTH PLAIN {}",
|
||||
STANDARD.encode("\0ann@example.com\0pw")
|
||||
)),
|
||||
send("235 ok\n"),
|
||||
expect("QUIT"),
|
||||
send("221 bye\n"),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut smtp = Smtp::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let bad = [
|
||||
(
|
||||
"ann@example.com",
|
||||
"bo@example.com>\r\nRCPT TO:<hidden@example.com",
|
||||
),
|
||||
(
|
||||
"ann@example.com>\r\nRCPT TO:<hidden@example.com",
|
||||
"bo@example.com",
|
||||
),
|
||||
("ann@example.com", "bo @example.com"),
|
||||
("ann@example.com", ""),
|
||||
(
|
||||
"ann@example.com",
|
||||
"\"bo\r\nRCPT TO:<hidden@example.com>\"@example.com",
|
||||
),
|
||||
("ann@example.com", "\"bo@example.com"),
|
||||
("ann@example.com", "bo@exa mple.com"),
|
||||
];
|
||||
for (from, to) in bad {
|
||||
let err = smtp
|
||||
.send(from, &[to.to_owned()], b"x\r\n")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(&err, Error::Invalid(_)), "{err}");
|
||||
}
|
||||
let err = smtp
|
||||
.send(
|
||||
"ann@example.com",
|
||||
&["bo@example.com".to_owned()],
|
||||
b"a\n.b\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Invalid(text) if text.contains("bare")),
|
||||
"{err}"
|
||||
);
|
||||
smtp.quit().await.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
async fn logged_in(
|
||||
ehlo: &str,
|
||||
steps: Vec<Step>,
|
||||
) -> (Smtp, tokio::task::JoinHandle<Result<(), String>>) {
|
||||
let certs = certs();
|
||||
let mut script = vec![
|
||||
send("220 mx ready\n"),
|
||||
expect("EHLO [127.0.0.1]"),
|
||||
send(ehlo),
|
||||
expect(&format!(
|
||||
"AUTH PLAIN {}",
|
||||
STANDARD.encode("\0ann@example.com\0pw")
|
||||
)),
|
||||
send("235 ok\n"),
|
||||
];
|
||||
script.extend(steps);
|
||||
let (server, script) = serve(&certs, Security::Tls, script).await;
|
||||
let smtp = Smtp::connect(
|
||||
&server,
|
||||
&login(Secret::Password("pw".into())),
|
||||
&certs.trusted,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
(smtp, script)
|
||||
}
|
||||
|
||||
/// MAIL, RCPT and DATA with the server agreeing.
|
||||
fn transaction(mail: &str, rcpt: &str, data: &str) -> Vec<Step> {
|
||||
vec![
|
||||
expect(mail),
|
||||
send("250 ok\n"),
|
||||
expect(rcpt),
|
||||
send("250 ok\n"),
|
||||
expect("DATA"),
|
||||
send("354 go\n"),
|
||||
Step::Data(data.to_owned()),
|
||||
send("250 queued\n"),
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_quoted_local_part_is_a_mailbox() {
|
||||
let from = r#""ann \"a\" lee"@example.com"#;
|
||||
let to = r#""bo smith"@[192.0.2.1]"#;
|
||||
let (mut smtp, script) = logged_in(
|
||||
"250-mx\n250 AUTH PLAIN\n",
|
||||
transaction(
|
||||
&format!("MAIL FROM:<{from}>"),
|
||||
&format!("RCPT TO:<{to}>"),
|
||||
"x",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
smtp.send(from, &[to.to_owned()], b"x\r\n").await.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_dot_opening_the_first_line_is_stuffed() {
|
||||
let (mut smtp, script) = logged_in(
|
||||
"250-mx\n250 AUTH PLAIN\n",
|
||||
transaction(
|
||||
"MAIL FROM:<ann@example.com>",
|
||||
"RCPT TO:<bo@example.com>",
|
||||
"..X-Trace: yes\nSubject: x\n\n..\nend",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
smtp.send(
|
||||
"ann@example.com",
|
||||
&["bo@example.com".to_owned()],
|
||||
b".X-Trace: yes\r\nSubject: x\r\n\r\n.\r\nend",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_utf8_header_needs_smtputf8() {
|
||||
let message = "Subject: 您好\r\n\r\nhi\r\n";
|
||||
let (mut smtp, script) = logged_in(
|
||||
"250-mx\n250-SMTPUTF8\n250 AUTH PLAIN\n",
|
||||
transaction(
|
||||
"MAIL FROM:<ann@example.com> SMTPUTF8",
|
||||
"RCPT TO:<bo@example.com>",
|
||||
"Subject: 您好\n\nhi",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let to = ["bo@example.com".to_owned()];
|
||||
smtp.send("ann@example.com", &to, message.as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
|
||||
let (mut smtp, script) = logged_in("250-mx\n250 AUTH PLAIN\n", vec![]).await;
|
||||
let err = smtp
|
||||
.send("ann@example.com", &to, message.as_bytes())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Invalid(text) if text.contains("headers")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
|
||||
let (mut smtp, script) = logged_in(
|
||||
"250-mx\n250 AUTH PLAIN\n",
|
||||
transaction(
|
||||
"MAIL FROM:<ann@example.com>",
|
||||
"RCPT TO:<bo@example.com>",
|
||||
"Subject: hi\n\n您好",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
smtp.send(
|
||||
"ann@example.com",
|
||||
&to,
|
||||
"Subject: hi\r\n\r\n您好\r\n".as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_utf8_address_needs_smtputf8() {
|
||||
let to = "用户@例子.广告";
|
||||
let (mut smtp, script) = logged_in(
|
||||
"250-mx\n250-SMTPUTF8\n250 AUTH PLAIN\n",
|
||||
transaction(
|
||||
"MAIL FROM:<ann@example.com> SMTPUTF8",
|
||||
&format!("RCPT TO:<{to}>"),
|
||||
"x",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
smtp.send("ann@example.com", &[to.to_owned()], b"x\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
script.await.unwrap().unwrap();
|
||||
|
||||
let (mut smtp, script) = logged_in("250-mx\n250 AUTH PLAIN\n", vec![]).await;
|
||||
let err = smtp
|
||||
.send("ann@example.com", &[to.to_owned()], b"x\r\n")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::Invalid(text) if text.contains("non-ASCII")),
|
||||
"{err}"
|
||||
);
|
||||
script.await.unwrap().unwrap();
|
||||
}
|
||||
+2
-2
@@ -131,10 +131,10 @@
|
||||
|
||||
第二批发布(用户定,2026-10-08):首版不发布 ElyMail,首版的完整测试与打包不等它。
|
||||
|
||||
用户 2026-10-19 定:后端改为 Rust crate(async-imap、async-pop、mail-send、mail-parser、mail-builder),取代 MailCore 2;Gmail 与 Microsoft 走 OAuth,iCloud 走应用专用密码;正文按块的子集由 GPUI 绘制,写信用 Markdown 文档组件;OAuth 客户端 ID 构建时注入(`oauth.env` 与 CI 机密,注册由用户做)。协议测试一律对本机的替身服务器(测试里按脚本应答的 IMAP、POP3、SMTP 与 OAuth 端点),不靠真网络;真服务器只在用户给出账号或 ID 后另验。界面 120 fps:界面线程不等网络与数据库,长列表只画可见行。
|
||||
用户 2026-10-19 定:后端改为 Rust crate(mail-send、mail-parser、mail-builder,IMAP 与 POP3 由引擎自写),取代 MailCore 2;Gmail 与 Microsoft 走 OAuth,iCloud 走应用专用密码;正文按块的子集由 GPUI 绘制,写信用 Markdown 文档组件;OAuth 客户端 ID 构建时注入(`oauth.env` 与 CI 机密,注册由用户做)。协议测试一律对本机的替身服务器(测试里按脚本应答的 IMAP、POP3、SMTP 与 OAuth 端点),不靠真网络;真服务器只在用户给出账号或 ID 后另验。界面 120 fps:界面线程不等网络与数据库,长列表只画可见行。
|
||||
|
||||
- [x] C12.0 选型修订:PRD §1.1、§12.1、§17.3、§21.2、§23 与 `agents.md` 改为上述选型;删去 MailCore 2 与 libetpan 的取码、构建与探测脚本;`oauth.env` 不入库;`references.sh` 随之(并修上游钉与工作区不一致的组件库钉) — R1 ok
|
||||
- [ ] C12.1 `crates/ely-mail-engine` 与连通(MAIL-01 前半):IMAP、POP3、SMTP 经 TLS(rustls,系统根证书)或 STARTTLS 连接,密码与 XOAUTH2 认证,失败带服务器原话;替身服务器测三协议的登录、拒绝、列邮箱、取信、发信
|
||||
- [x] C12.1 `crates/ely-mail-engine` 与连通(MAIL-01 前半):IMAP、POP3、SMTP 经 TLS(rustls,系统根证书)或 STARTTLS 连接,密码与 XOAUTH2 认证,失败带服务器原话;替身服务器测三协议的登录、拒绝、列邮箱、取信、发信。已做:`Imap`、`Pop`、`Smtp` 三个会话,`Server`(主机、端口、`Security::Tls` 或 `StartTls`,无明文)、`Login`(密码或 OAuth 令牌);系统信任库经 rustls-platform-verifier,测试换入只信测试证书的连接器;每步 60 秒超时;`Error` 分连接、TLS、凭据被拒(服务器原话)、命令被拒、读不懂、超时、发不出(信封地址或登录带换行等)。IMAP 由引擎自写,以 imap-proto 解析(`wire.rs` 收一个个完整回复,含字面量),换下 async-imap(它的 LIST、FETCH 不看结尾的 NO 与 BAD,拒绝成了空结果与“无此 UID”;登录与命令中途的 `* BYE` 被丢掉,只剩断线;trace 日志写出密码与邮件):每条命令看结尾的状态,NO 与 BAD 带原话报出(登录为凭据被拒,其余为命令被拒);问候或任何时候收到 `* BYE`,此后断线即带其原话;非 ASCII 或带换行的参数以字面量发出,等服务器的 `+`;STARTTLS 之后若已有明文字节即拒绝;LIST 里带引号的邮箱名去掉转义(imap-proto 照原样留着),字面量照原样,列出的名字可直接打开;日志只记标签与命令名。POP3 由引擎自写(RFC 1939:问候、STLS、USER/PASS、AUTH XOAUTH2、UIDL、RETR、QUIT),换下 async-pop(它留着点填充、丢末尾 CRLF、吞掉登录后 CAPA 的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码与邮件);STLS 之后若已有明文字节即拒绝;用户名与密码带换行即拒绝。SMTP 的 XOAUTH2 与事务由引擎自走(mail-send 遇错误挑战会重发令牌;它的 DATA 不给首行的点加点,且自加末尾 CRLF):信封地址照 RFC 5321 的 Mailbox 核对(点串或带引号的本地部分,域名或地址字面量;地址或信头有 UTF-8 时,服务器报 SMTPUTF8 才发出并带此参数,否则拒绝(RFC 6531)),不合即拒绝、不发任何命令;DATA 每行开头的点(含首行)加一个点,裸 CR 或 LF 即拒绝。服务器拒绝事务中的一步后发 RSET,会话可再发信。三协议的 XOAUTH2 被拒都以空行答错误挑战、留下服务器最后的原话。34 个测试(`cargo test -p ely-mail-engine`,含以真日志器在 trace 下跑三协议、日志里无密码、令牌与正文),真服务器未连。审查三轮:第 3 轮的两处(带引号的邮箱名、信头的 UTF-8)已修并以测试核对,未再审查(每条至多三轮)
|
||||
- [ ] C12.2 MIME(MAIL-04、05 的基础):mail-parser 读出头、纯文本、HTML、cid 内嵌图片、附件与原始头;mail-builder 写出多部分邮件、附件、Message-ID、In-Reply-To 与 References;样本 EML 往返测试
|
||||
- [ ] C12.3 本地存储(§12.3、§18.1、§18.3):SQLite 索引(账号、邮箱、消息身份为账号加邮箱加 UIDVALIDITY 加 UID、标记、头字段)、原文按内容存进内容目录、FTS5 全文索引(可重建)、结构版本与升级前备份、升级失败只读;各类大小可查
|
||||
- [ ] C12.4 凭据与 OAuth(MAIL-02,§18.2):密码与刷新令牌只进系统凭据库(不可用即只留到本次退出并写明);Gmail 与 Microsoft 的授权码加 PKCE、本机回环、换令牌与刷新;客户端 ID 编进构建,缺 ID 即写明不可用;刷新被拒转为“需重新登录”;替身授权端点测试
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
FAIL
|
||||
|
||||
1. `crates/ely-mail-engine/src/smtp.rs:70` — [P1] SMTP envelope addresses can inject additional commands. `from` and every `to` string reach mail-send's command formatting without validation. Passing one recipient, `bo@example.com>\r\nRCPT TO:<hidden@example.com`, transmits two separate `RCPT TO` commands. A scripted TLS server accepted both recipients and DATA, and `send` returned `Ok(())`, silently delivering to an extra envelope recipient. Reject invalid envelope mailbox syntax, including CR/LF, for the sender and all recipients before transmitting the transaction; add regression coverage for both inputs. Reproduced on Windows and Linux: `smtp_envelope_cannot_inject_an_additional_recipient`, `.work/c12.1-r1-probe.log` and `.work/c12.1-r1-linux-probe.log`.
|
||||
|
||||
2. `crates/ely-mail-engine/Cargo.toml:10` — [P1] The selected async-imap and async-pop dependencies emit credentials and complete fetched messages through the shared `log` facade at trace level. Neither the dependency configuration nor the engine suppresses or redacts those records. With trace enabled, successful TLS login and fetch recorded the literal IMAP password, POP3 `PASS` password, and both message bodies. The project's logger enables these targets with `ELY_LOG=trace`, violating agents.md's prohibition on credentials and content in logs. Remove or redact sensitive protocol tracing, or reliably exclude those records from logging, and test actual dependency logs rather than only the engine's own messages. Reproduced on both platforms: `trace_logs_exclude_password_token_and_message`, the two probe logs above.
|
||||
|
||||
3. `crates/ely-mail-engine/src/smtp.rs:62` — [P2] SMTP XOAUTH2 does not complete an authentication error challenge correctly. After the initial token, a server replying `334 <base64 JSON error>` receives the token again from mail-send's generic authentication loop. The required response is an empty line, after which the server supplies its final `535` refusal ([Gmail protocol](https://developers.google.com/workspace/gmail/imap/xoauth2-protocol#error_response_3)). The scripted server expecting that exchange instead observed the repeated token; `connect` returned a connection error and lost the final refusal. Implement the stateful XOAUTH2 error exchange for SMTP and preserve the final server reply. Existing SMTP tests cover password refusal and token success only. Reproduced on both platforms: `smtp_refused_oauth_answers_the_error_challenge_empty`, the two probe logs above.
|
||||
|
||||
4. `crates/ely-mail-engine/src/pop.rs:106` — [P2] POP3 XOAUTH2 stops handling challenges immediately after sending the token. If the server then returns a base64 error challenge, async-pop receives a challenge where it expects final success and returns `Protocol("Did not received the expected auith response")`; the engine never sends the empty response and never reads the server's final `-ERR` explanation. This loses the required credential refusal and server wording for the documented [XOAUTH2 error exchange](https://developers.google.com/workspace/gmail/imap/xoauth2-protocol#error_response). Complete the challenge/final-response exchange, preserving both successful authentication and the final rejection; add a token-refusal test. Reproduced on both platforms: `pop_refused_oauth_answers_the_error_challenge_empty`, the two probe logs above.
|
||||
|
||||
5. `crates/ely-mail-engine/src/pop.rs:32` — [P2] POP3 login can report success after swallowing a connection failure. Both async-pop authentication paths call `update_capabilities`, which discards every CAPA error. A TLS server that answers USER/PASS successfully and then closes after receiving the post-login CAPA causes `Pop::connect` to return `Ok(Pop)` and emit the successful-login log despite the failed operation and unusable connection. Surface transport and malformed-response errors from capability discovery; distinguish an explicitly handled unsupported CAPA reply from other failures. Cover the same behavior for password and OAuth authentication. Reproduced on both platforms: `pop_connection_loss_during_capabilities_is_an_error`, the two probe logs above.
|
||||
|
||||
6. `crates/ely-mail-engine/src/imap.rs:101` — [P2] The IMAP greeting is discarded without checking its response type or status. When a TLS server sends `* BYE [UNAVAILABLE] Maintenance in progress` and closes, the client treats the greeting as successful and attempts authentication. The returned error is an OS connection failure (Windows 10053 / Linux broken pipe), with the already-received maintenance explanation lost. This breaks C12.1's requirement to retain server wording on failure. Interpret the greeting, return BYE's reason immediately, and avoid authentication after a refusing greeting; add a greeting-refusal regression. Reproduced on both platforms: `imap_greeting_refusal_keeps_the_server_words`, the two probe logs above.
|
||||
@@ -0,0 +1,9 @@
|
||||
FAIL
|
||||
|
||||
1. `crates/ely-mail-engine/src/imap.rs:60` — [P2] IMAP command refusals become successful empty results. async-imap's LIST and FETCH streams discard the tagged completion without checking its status. A server replying to LIST with `NO [UNAVAILABLE] Mailbox listing temporarily unavailable` makes `mailboxes()` return `Ok([])`; replying to UID FETCH with `NO [UNAVAILABLE] Message store temporarily unavailable` makes `fetch()` return `Ok(None)`, falsely reporting a missing UID. Both lose the server's explanation and swallow an operation failure. Ensure the tagged completion is checked for NO/BAD before returning collected results, and cover refusals for both operations. Reproduced on Windows and Linux: `imap_list_refusal_is_an_error`, `imap_fetch_refusal_is_an_error`; `.work/c12.1-r2-probe.log`, `.work/c12.1-r2-linux-probe.log`.
|
||||
|
||||
2. `crates/ely-mail-engine/src/imap.rs:44` — [P2] BYE explanations are still discarded during authentication. The round 1 greeting scenario is fixed, but after a normal greeting, a server answering LOGIN with `* BYE [UNAVAILABLE] Too many simultaneous connections` and closing produces only `cannot connect: peer closed connection without sending TLS close_notify`. async-imap ignores the untagged BYE while waiting for a tagged completion. This still violates C12.1's requirement to preserve server wording on failure. Propagate BYE and its explanation through authentication and command response handling, rather than handling it only as the initial greeting; add regression coverage after authentication starts. Reproduced on both platforms: `imap_login_bye_keeps_server_words`, the two probe logs above.
|
||||
|
||||
3. `crates/ely-mail-engine/src/smtp.rs:131` — [P2] Envelope validation rejects valid quoted mailbox addresses. For example, sending to `"bo smith"@example.com` returns `Error::Invalid` before any transaction, so that recipient cannot receive mail through this engine. Spaces inside a quoted local part are permitted by [RFC 5321 §4.1.2](https://www.rfc-editor.org/rfc/rfc5321.html#section-4.1.2). Validate SMTP mailbox grammar with quoting awareness while retaining command-injection protection, and test valid quoted senders and recipients alongside the injection cases. Reproduced on both platforms: `smtp_accepts_a_quoted_local_part`, the two probe logs above.
|
||||
|
||||
4. `crates/ely-mail-engine/src/smtp.rs:82` — [P2] SMTP DATA omits dot stuffing on the first line. A valid message beginning with the optional header `.X-Trace: yes` is transmitted with that single initial period, whereas subsequent dot-prefixed lines are escaped. A conforming receiver removes the period and changes the header name: [RFC 5321 §4.5.2](https://www.rfc-editor.org/rfc/rfc5321.html#section-4.5.2) requires escaping each dot-prefixed line, and [RFC 5322 §3.6.8](https://www.rfc-editor.org/rfc/rfc5322.html#section-3.6.8) permits this header name. The selected mail-send writer only escapes dots following CR/LF. Correct the transparency handling at the first byte as well, and test the exact DATA bytes of a message starting with a period. Reproduced on both platforms: `smtp_stuffs_a_dot_in_the_first_data_line`, the two probe logs above.
|
||||
@@ -0,0 +1,5 @@
|
||||
FAIL
|
||||
|
||||
1. `crates/ely-mail-engine/src/imap.rs:68` — [P2] Quoted LIST mailbox names retain their wire escapes. imap-proto's quoted-string parser returns the escaped interior, so a server listing the folder `a"b` as `"a\"b"` makes `mailboxes()` return a name containing an extra backslash. Passing that returned name to `fetch()` escapes it again in EXAMINE and addresses a different folder, preventing retrieval from the listed folder. Backslashes are similarly doubled. Decode quoted-string escapes at the response boundary, preserving literal mailbox contents unchanged, and cover LIST-to-fetch round trips for both representations ([RFC 3501 string syntax](https://www.rfc-editor.org/rfc/rfc3501.html#section-9)). Reproduced on Windows and Linux: `imap_list_decodes_quoted_mailbox_names` and `imap_fetch_from_a_listed_quoted_mailbox_uses_its_name`; literal-name control passes. Evidence: `.work/c12.1-r3-windows-probe.log`, `.work/c12.1-r3-linux-probe.log`.
|
||||
|
||||
2. `crates/ely-mail-engine/src/smtp.rs:85` — [P2] SMTPUTF8 is checked only for envelope addresses, ignoring internationalized message headers. With ASCII sender and recipients but `Subject: 您好`, the engine omits SMTPUTF8 from MAIL even when advertised. When the server does not advertise it, the engine still transmits the UTF-8 header and can return `Ok(())`. This violates [RFC 6531 §3.2 and §3.4](https://www.rfc-editor.org/rfc/rfc6531.html#section-3.2) and can make otherwise supported internationalized messages fail submission or subsequent relay. Include the message's SMTPUTF8 requirement in the send contract and capability check, reject unsupported messages before MAIL, and add the parameter when required; test ASCII envelopes with UTF-8 headers against both capability responses. Reproduced on Windows and Linux: `smtp_utf8_header_requests_smtputf8_with_ascii_envelope` and `smtp_utf8_header_refused_without_smtputf8`; the two probe logs above.
|
||||
Reference in new issue
Block a user