mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat(smtp): implement built-in SMTP server for mail ingestion
- Add lightweight SMTP server support using `lettre` and `tokio`. - Implement `DATA_SMTP_INGEST` permission check for inbound mail. - Support real-time email archiving via SMTP protocol. - Integrate with existing EML index manager for automated indexing.
This commit is contained in:
Generated
+342
-4
@@ -193,6 +193,15 @@ version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "ar_archive_writer"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
|
||||
dependencies = [
|
||||
"object",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
@@ -426,6 +435,45 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60"
|
||||
dependencies = [
|
||||
"asn1-rs-derive",
|
||||
"asn1-rs-impl",
|
||||
"displaydoc",
|
||||
"nom 7.1.3",
|
||||
"num-traits",
|
||||
"rusticata-macros",
|
||||
"thiserror 2.0.17",
|
||||
"time 0.3.47",
|
||||
]
|
||||
|
||||
[[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.111",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[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.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "1.9.0"
|
||||
@@ -505,7 +553,7 @@ dependencies = [
|
||||
"futures",
|
||||
"imap-proto",
|
||||
"log",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"pin-project",
|
||||
"pin-utils",
|
||||
"self_cell",
|
||||
@@ -699,6 +747,7 @@ dependencies = [
|
||||
"imap-proto",
|
||||
"itertools",
|
||||
"itoa",
|
||||
"lettre",
|
||||
"lru 0.16.3",
|
||||
"mail-parser",
|
||||
"mail-send",
|
||||
@@ -717,6 +766,7 @@ dependencies = [
|
||||
"poem-openapi",
|
||||
"r2d2",
|
||||
"rand 0.10.0",
|
||||
"rcgen",
|
||||
"refinery",
|
||||
"refinery-core",
|
||||
"regex",
|
||||
@@ -724,6 +774,7 @@ dependencies = [
|
||||
"ring",
|
||||
"rust-embed",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"semver 1.0.27",
|
||||
"serde",
|
||||
@@ -1064,6 +1115,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chumsky"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
"stacker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.2.5"
|
||||
@@ -1270,6 +1331,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -1428,7 +1499,7 @@ checksum = "79fc3b6dd0b87ba36e565715bf9a2ced221311db47bd18011676f24a6066edbc"
|
||||
dependencies = [
|
||||
"curl-sys",
|
||||
"libc",
|
||||
"openssl-probe",
|
||||
"openssl-probe 0.1.6",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"socket2 0.6.1",
|
||||
@@ -1547,6 +1618,20 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204"
|
||||
|
||||
[[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.5"
|
||||
@@ -1676,6 +1761,16 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
@@ -1876,6 +1971,21 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -2197,6 +2307,10 @@ name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -2371,6 +2485,17 @@ dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hostname"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html2text"
|
||||
version = "0.16.7"
|
||||
@@ -2719,7 +2844,7 @@ version = "0.16.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba1f9b30846c3d04371159ef3a0413ce7c1ae0a8c619cd255c60b3d902553f22"
|
||||
dependencies = [
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2880,6 +3005,31 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chumsky",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand 2.3.0",
|
||||
"futures-util",
|
||||
"hostname",
|
||||
"httpdate",
|
||||
"idna 1.1.0",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"nom 8.0.0",
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"socket2 0.6.1",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "levenshtein_automata"
|
||||
version = "0.2.1"
|
||||
@@ -3363,6 +3513,23 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b"
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe 0.2.1",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native_db"
|
||||
version = "0.8.2"
|
||||
@@ -3444,6 +3611,15 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
@@ -3606,6 +3782,15 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.37.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oem_cp"
|
||||
version = "1.3.0"
|
||||
@@ -3616,6 +3801,15 @@ dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[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.3"
|
||||
@@ -3644,12 +3838,44 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.5.4+3.5.4"
|
||||
@@ -3730,6 +3956,16 @@ dependencies = [
|
||||
"hmac 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -4044,6 +4280,16 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
|
||||
dependencies = [
|
||||
"ar_archive_writer",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptr_meta"
|
||||
version = "0.1.4"
|
||||
@@ -4185,6 +4431,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
@@ -4364,6 +4616,20 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e"
|
||||
dependencies = [
|
||||
"pem",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"time 0.3.47",
|
||||
"x509-parser",
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redb"
|
||||
version = "1.5.1"
|
||||
@@ -4674,6 +4940,15 @@ dependencies = [
|
||||
"semver 1.0.27",
|
||||
]
|
||||
|
||||
[[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.4"
|
||||
@@ -4785,6 +5060,29 @@ version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.2.1"
|
||||
@@ -5177,6 +5475,19 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "stacker"
|
||||
version = "0.1.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "standback"
|
||||
version = "0.2.17"
|
||||
@@ -5519,7 +5830,7 @@ version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "768fccdc84d60d86235d42d7e4c33acf43c418258ff5952abf07bd7837fcd26b"
|
||||
dependencies = [
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6975,6 +7286,24 @@ dependencies = [
|
||||
"tap",
|
||||
]
|
||||
|
||||
[[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.17",
|
||||
"time 0.3.47",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
@@ -6997,6 +7326,15 @@ version = "0.8.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
|
||||
dependencies = [
|
||||
"time 0.3.47",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
|
||||
+3
-1
@@ -133,8 +133,10 @@ arrow = { version = "56.2.0", features = ["ffi"] }
|
||||
r2d2 = { version = "0.8", default-features = false }
|
||||
refinery = { version = "0.9", default-features = false }
|
||||
refinery-core = { version = "0.9", default-features = false }
|
||||
|
||||
rcgen = "0.14.7"
|
||||
rustls-pemfile = "2.2.0"
|
||||
[dev-dependencies]
|
||||
#bincode = "1.3.3"
|
||||
#secret-lib = "1.0.0"
|
||||
tempfile = "3.26.0"
|
||||
lettre = "0.11.19"
|
||||
+30
-2
@@ -22,14 +22,17 @@ use bichon::{
|
||||
common::rustls::RustMailerTls,
|
||||
context::{executors::BichonContext, Initialize},
|
||||
duckdb::init::DuckDBManager,
|
||||
error::BichonResult,
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
logger,
|
||||
rest::start_http_server,
|
||||
settings::cli::SETTINGS,
|
||||
smtp::{start_smtp_server, SmtpServer},
|
||||
tasks::PeriodicTasks,
|
||||
},
|
||||
raise_error,
|
||||
};
|
||||
use mimalloc::MiMalloc;
|
||||
use tracing::info;
|
||||
use tracing::{error, info};
|
||||
|
||||
use bichon::modules::{
|
||||
common::signal::SignalManager, settings::dir::DataDirManager, users::manager::UserManager,
|
||||
@@ -61,7 +64,32 @@ async fn main() -> BichonResult<()> {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
let mut smtp_service: Option<SmtpServer> = None;
|
||||
if SETTINGS.bichon_enable_smtp {
|
||||
info!("SMTP service is enabled, starting...");
|
||||
match start_smtp_server().await {
|
||||
Ok(server) => {
|
||||
info!("SMTP server listening on: {}", server.smtp_addr);
|
||||
smtp_service = Some(server);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to start SMTP server: {}", e);
|
||||
return Err(raise_error!(format!("{:#?}", e), ErrorCode::InternalError));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("SMTP service is disabled by configuration.");
|
||||
}
|
||||
|
||||
start_http_server().await?;
|
||||
|
||||
if let Some(server) = smtp_service {
|
||||
info!("Shutting down SMTP server...");
|
||||
server.stop().await;
|
||||
info!("SMTP server stopped.");
|
||||
}
|
||||
|
||||
info!("Bichon server stopped.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -421,6 +421,17 @@ impl AccountV4 {
|
||||
list_all_impl(DB_MANAGER.meta_db()).await
|
||||
}
|
||||
|
||||
pub async fn find_by_email(email: &str) -> BichonResult<Option<AccountModel>> {
|
||||
let all: Vec<AccountModel> = list_all_impl(DB_MANAGER.meta_db()).await?;
|
||||
let target_email = email.trim().to_lowercase();
|
||||
|
||||
let first_match = all
|
||||
.into_iter()
|
||||
.find(|acc| acc.email.to_lowercase() == target_email);
|
||||
|
||||
Ok(first_match)
|
||||
}
|
||||
|
||||
pub async fn minimal_list(only_nosync: bool) -> BichonResult<Vec<MinimalAccount>> {
|
||||
let result = list_all_impl(DB_MANAGER.meta_db())
|
||||
.await?
|
||||
|
||||
@@ -93,6 +93,37 @@ impl ClientContext {
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn check_has_permission(user: &UserModel, account_id: Option<u64>, permission: &str) -> bool {
|
||||
if user.is_admin().await {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut global_perms = HashSet::new();
|
||||
for rid in &user.global_roles {
|
||||
if let Some(role) = UserRole::find(*rid).await.ok().flatten() {
|
||||
global_perms.extend(role.permissions);
|
||||
}
|
||||
}
|
||||
|
||||
if Self::check_global_logic(&global_perms, permission) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(aid) = account_id {
|
||||
if let Some(role_id) = user.account_access_map.get(&aid) {
|
||||
if let Some(role) = UserRole::find(*role_id).await.ok().flatten() {
|
||||
if role.permissions.contains(&permission.to_string())
|
||||
|| Self::check_account_logic(&role.permissions, permission)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn has_permission(&self, account_id: Option<u64>, permission: &str) -> bool {
|
||||
if self.user.is_admin().await {
|
||||
return true;
|
||||
@@ -105,7 +136,7 @@ impl ClientContext {
|
||||
}
|
||||
}
|
||||
|
||||
if self.check_global_logic(&global_perms, permission) {
|
||||
if Self::check_global_logic(&global_perms, permission) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -113,7 +144,7 @@ impl ClientContext {
|
||||
if let Some(role_id) = self.user.account_access_map.get(&aid) {
|
||||
if let Some(role) = UserRole::find(*role_id).await.ok().flatten() {
|
||||
if role.permissions.contains(&permission.to_string())
|
||||
|| self.check_account_logic(&role.permissions, permission)
|
||||
|| Self::check_account_logic(&role.permissions, permission)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -124,7 +155,7 @@ impl ClientContext {
|
||||
false
|
||||
}
|
||||
|
||||
fn check_global_logic(&self, global: &HashSet<String>, perm: &str) -> bool {
|
||||
fn check_global_logic(global: &HashSet<String>, perm: &str) -> bool {
|
||||
if global.contains(perm) {
|
||||
return true;
|
||||
}
|
||||
@@ -141,7 +172,7 @@ impl ClientContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn check_account_logic(&self, scoped_perms: &BTreeSet<String>, perm: &str) -> bool {
|
||||
fn check_account_logic(scoped_perms: &BTreeSet<String>, perm: &str) -> bool {
|
||||
if scoped_perms.contains(perm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,21 @@ pub fn extract_envelope_from_eml(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn extract_envelope_from_smtp(
|
||||
body: &[u8],
|
||||
account_id: u64,
|
||||
mailbox_id: u64,
|
||||
) -> BichonResult<(Envelope, Vec<AttachmentInfo>)> {
|
||||
extract_envelope_core(
|
||||
body,
|
||||
0,
|
||||
body.len() as u32,
|
||||
utc_now!(),
|
||||
account_id,
|
||||
mailbox_id,
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_envelope_core(
|
||||
body: &[u8],
|
||||
uid: u32,
|
||||
|
||||
@@ -36,6 +36,7 @@ pub mod message;
|
||||
pub mod oauth2;
|
||||
pub mod rest;
|
||||
pub mod settings;
|
||||
pub mod smtp;
|
||||
pub mod tasks;
|
||||
pub mod token;
|
||||
pub mod users;
|
||||
|
||||
@@ -18,13 +18,14 @@
|
||||
|
||||
use crate::modules::common::error::ErrorCapture;
|
||||
use crate::modules::common::log::Tracing;
|
||||
use crate::modules::common::signal::SIGNAL_MANAGER;
|
||||
use crate::modules::common::tls::rustls_config;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::handler::error_handler;
|
||||
use crate::modules::error::BichonResult;
|
||||
use crate::modules::rest::public::login::login;
|
||||
use crate::modules::rest::public::status::get_status;
|
||||
use crate::modules::{settings::cli::SETTINGS, utils::shutdown::shutdown_signal};
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
|
||||
use super::error::ApiErrorResponse;
|
||||
use crate::modules::common::auth::ApiGuard;
|
||||
@@ -137,12 +138,16 @@ pub async fn start_http_server() -> BichonResult<()> {
|
||||
.with_if(SETTINGS.bichon_http_compression_enabled, Compression::new())
|
||||
.with(CatchPanic::new());
|
||||
|
||||
let mut rx = SIGNAL_MANAGER.subscribe();
|
||||
let shutdown_fut = async move {
|
||||
let _ = rx.recv().await;
|
||||
};
|
||||
let server = Server::new(listener)
|
||||
.name("Bichon Service")
|
||||
.idle_timeout(Duration::from_secs(60))
|
||||
.run_with_graceful_shutdown(
|
||||
route.catch_all_error(error_handler),
|
||||
shutdown_signal(),
|
||||
shutdown_fut,
|
||||
Some(Duration::from_secs(5)),
|
||||
);
|
||||
println!(
|
||||
|
||||
@@ -300,6 +300,73 @@ pub struct Settings {
|
||||
help = "Set the Tantivy docstore block size in bytes (default: 2MB)"
|
||||
)]
|
||||
pub bichon_eml_blocksize: usize,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
default_value = "false",
|
||||
help = "Enable the embedded SMTP server for real-time email receiving"
|
||||
)]
|
||||
pub bichon_enable_smtp: bool,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
help = "Path to the SMTP TLS private key file (e.g., key.pem)",
|
||||
value_parser = ValueParser::new(|s: &str| {
|
||||
let path = PathBuf::from(s);
|
||||
if !path.is_absolute() {
|
||||
return Err("'bichon_smtp_tls_key_path' must be an absolute path".to_string());
|
||||
}
|
||||
if !path.exists() {
|
||||
return Err(format!("SMTP TLS key file not found: {}", s));
|
||||
}
|
||||
Ok(s.to_string())
|
||||
})
|
||||
)]
|
||||
pub bichon_smtp_tls_key_path: Option<String>,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
help = "Path to the SMTP TLS certificate chain file (e.g., cert.pem)",
|
||||
value_parser = ValueParser::new(|s: &str| {
|
||||
let path = PathBuf::from(s);
|
||||
if !path.is_absolute() {
|
||||
return Err("'bichon_smtp_tls_cert_path' must be an absolute path".to_string());
|
||||
}
|
||||
if !path.exists() {
|
||||
return Err(format!("SMTP TLS certificate file not found: {}", s));
|
||||
}
|
||||
Ok(s.to_string())
|
||||
})
|
||||
)]
|
||||
pub bichon_smtp_tls_cert_path: Option<String>,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
default_value = "2525",
|
||||
env,
|
||||
help = "Set the SMTP port for Bichon (e.g., 25 or 2525). Note: Port 25 may require root privileges.",
|
||||
value_parser = clap::value_parser!(u16).range(1..)
|
||||
)]
|
||||
pub bichon_smtp_port: u16,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
default_value = "starttls",
|
||||
help = "Set the encryption mode for SMTP: 'none', 'starttls', or 'tls'"
|
||||
)]
|
||||
pub bichon_smtp_encryption: SmtpEncryptionMode,
|
||||
|
||||
#[clap(
|
||||
long,
|
||||
env,
|
||||
default_value = "true",
|
||||
help = "Enable SMTP authentication requirement"
|
||||
)]
|
||||
pub bichon_smtp_auth_required: bool,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
@@ -339,3 +406,23 @@ impl fmt::Display for CompressionAlgorithm {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
|
||||
pub enum SmtpEncryptionMode {
|
||||
#[clap(name = "none")]
|
||||
None,
|
||||
#[clap(name = "starttls")]
|
||||
Starttls,
|
||||
#[clap(name = "tls")]
|
||||
Tls,
|
||||
}
|
||||
|
||||
impl fmt::Display for SmtpEncryptionMode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SmtpEncryptionMode::None => write!(f, "none"),
|
||||
SmtpEncryptionMode::Starttls => write!(f, "starttls"),
|
||||
SmtpEncryptionMode::Tls => write!(f, "tls"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
|
||||
use crate::modules::{
|
||||
common::signal::SIGNAL_MANAGER,
|
||||
settings::cli::{SmtpEncryptionMode, SETTINGS},
|
||||
smtp::{
|
||||
server::{run_smtp_server, run_smtps_server},
|
||||
tls::create_acceptor,
|
||||
},
|
||||
};
|
||||
|
||||
pub mod server;
|
||||
pub mod stream;
|
||||
pub mod tls;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SmtpConfig {
|
||||
pub whitelist: Option<Vec<String>>,
|
||||
pub tls_acceptor: Option<TlsAcceptor>,
|
||||
pub auth_required: bool,
|
||||
}
|
||||
|
||||
pub struct SmtpServer {
|
||||
pub smtp_addr: SocketAddr,
|
||||
smtp_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl SmtpServer {
|
||||
pub async fn stop(self) {
|
||||
let _ = self.smtp_handle.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_smtp_server() -> std::io::Result<SmtpServer> {
|
||||
let smtp_port = SETTINGS.bichon_smtp_port;
|
||||
|
||||
let tls_acceptor: Option<TlsAcceptor> = match SETTINGS.bichon_smtp_encryption {
|
||||
SmtpEncryptionMode::None => None,
|
||||
SmtpEncryptionMode::Starttls | SmtpEncryptionMode::Tls => Some(create_acceptor().await?),
|
||||
};
|
||||
|
||||
let smtp_listener = TcpListener::bind((
|
||||
SETTINGS.bichon_bind_ip.clone().unwrap_or("0.0.0.0".into()),
|
||||
smtp_port,
|
||||
))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::AddrInUse {
|
||||
std::io::Error::other(format!(
|
||||
"SMTP port {smtp_port} is already in use. Is another instance running?"
|
||||
))
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
let smtp_addr = smtp_listener.local_addr()?;
|
||||
|
||||
let smtp_config = SmtpConfig {
|
||||
whitelist: None,
|
||||
tls_acceptor: match SETTINGS.bichon_smtp_encryption {
|
||||
SmtpEncryptionMode::None | SmtpEncryptionMode::Tls => None,
|
||||
SmtpEncryptionMode::Starttls => tls_acceptor.clone(),
|
||||
},
|
||||
auth_required: SETTINGS.bichon_smtp_auth_required,
|
||||
};
|
||||
|
||||
let smtp_shutdown = SIGNAL_MANAGER.subscribe();
|
||||
|
||||
let smtp_handle = if matches!(SETTINGS.bichon_smtp_encryption, SmtpEncryptionMode::Tls) {
|
||||
let acceptor = tls_acceptor
|
||||
.clone()
|
||||
.expect("TLS acceptor required when tls=true");
|
||||
tokio::spawn(async move {
|
||||
run_smtps_server(smtp_listener, smtp_config, acceptor, smtp_shutdown).await;
|
||||
})
|
||||
} else {
|
||||
tokio::spawn(async move {
|
||||
run_smtp_server(smtp_listener, smtp_config, smtp_shutdown).await;
|
||||
})
|
||||
};
|
||||
|
||||
Ok(SmtpServer {
|
||||
smtp_addr,
|
||||
smtp_handle,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::modules::cache::imap::mailbox::{Attribute, AttributeEnum};
|
||||
use crate::modules::envelope::extractor::extract_envelope_from_smtp;
|
||||
use crate::modules::error::BichonResult;
|
||||
use crate::modules::indexer::manager::EML_INDEX_MANAGER;
|
||||
use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER;
|
||||
use crate::modules::indexer::schema::SchemaTools;
|
||||
use crate::modules::utils::create_hash;
|
||||
use crate::modules::{
|
||||
account::migration::AccountModel,
|
||||
cache::imap::mailbox::MailBox,
|
||||
common::auth::ClientContext,
|
||||
smtp::{stream::BufStream, SmtpConfig},
|
||||
token::AccessTokenModel,
|
||||
users::{permissions::Permission, UserModel},
|
||||
};
|
||||
use base64::{prelude::BASE64_STANDARD, Engine as _};
|
||||
use tantivy::doc;
|
||||
use tokio::time::timeout;
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt},
|
||||
net::{TcpListener, TcpStream},
|
||||
sync::broadcast,
|
||||
};
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
|
||||
const MAX_MAIL_SIZE: usize = 50 * 1024 * 1024; //50MB
|
||||
const SMTP_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const GLOBAL_SESSION_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
pub async fn run_smtp_server(
|
||||
listener: TcpListener,
|
||||
config: SmtpConfig,
|
||||
mut shutdown: broadcast::Receiver<()>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = listener.accept() => {
|
||||
match result {
|
||||
Ok((stream, addr)) => {
|
||||
tracing::debug!("SMTP connection from {addr}");
|
||||
let config = config.clone();
|
||||
tokio::spawn(async move {
|
||||
let res = timeout(GLOBAL_SESSION_TIMEOUT, handle_connection(stream, config)).await;
|
||||
match res {
|
||||
Ok(Ok(_)) => tracing::debug!("SMTP session from {addr} finished"),
|
||||
Ok(Err(e)) => tracing::debug!("SMTP session error from {addr}: {e}"),
|
||||
Err(_) => tracing::warn!("SMTP session from {addr} timed out after {}s", GLOBAL_SESSION_TIMEOUT.as_secs()),
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to accept connection: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown.recv() => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_smtps_server(
|
||||
listener: TcpListener,
|
||||
config: SmtpConfig,
|
||||
tls_acceptor: TlsAcceptor,
|
||||
mut shutdown: broadcast::Receiver<()>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = listener.accept() => {
|
||||
match result {
|
||||
Ok((stream, addr)) => {
|
||||
tracing::debug!("SMTPS connection from {addr}");
|
||||
let config = config.clone();
|
||||
let acceptor = tls_acceptor.clone();
|
||||
tokio::spawn(async move {
|
||||
match acceptor.accept(stream).await {
|
||||
Ok(tls_stream) => {
|
||||
let res = timeout(GLOBAL_SESSION_TIMEOUT, handle_tls_connection(tls_stream, config)).await;
|
||||
match res {
|
||||
Ok(Ok(_)) => tracing::debug!("SMTPS session from {addr} finished"),
|
||||
Ok(Err(e)) => tracing::debug!("SMTPS session error from {addr}: {e}"),
|
||||
Err(_) => tracing::warn!("SMTPS session from {addr} timed out after {}s", GLOBAL_SESSION_TIMEOUT.as_secs()),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("TLS handshake failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to accept connection: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown.recv() => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CommandResult {
|
||||
Continue,
|
||||
Quit,
|
||||
StartTls,
|
||||
}
|
||||
|
||||
struct Session {
|
||||
mail_from: Option<String>,
|
||||
rcpt_to: Vec<AccountModel>,
|
||||
authenticated: bool,
|
||||
user: Option<UserModel>,
|
||||
auth_required: bool,
|
||||
tls_active: bool,
|
||||
auth_state: AuthState,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
const fn new(auth_required: bool, tls_active: bool) -> Self {
|
||||
Self {
|
||||
mail_from: None,
|
||||
rcpt_to: Vec::new(),
|
||||
authenticated: false,
|
||||
user: None,
|
||||
auth_required,
|
||||
tls_active,
|
||||
auth_state: AuthState::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.mail_from = None;
|
||||
self.rcpt_to.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
enum AuthState {
|
||||
#[default]
|
||||
None,
|
||||
WaitingForPlain,
|
||||
WaitingForLoginUsername,
|
||||
WaitingForLoginPassword(String),
|
||||
}
|
||||
|
||||
/// Handle a plain TCP connection with optional STARTTLS upgrade.
|
||||
async fn handle_connection(stream: TcpStream, config: SmtpConfig) -> io::Result<()> {
|
||||
let mut session = Session::new(config.auth_required, false);
|
||||
|
||||
// Use buffered I/O over the raw stream
|
||||
let mut stream = BufStream::new(stream);
|
||||
|
||||
stream
|
||||
.write_all(b"220 localhost ESMTP (Bichon Email Archiver)\r\n")
|
||||
.await?;
|
||||
stream.flush().await?;
|
||||
|
||||
loop {
|
||||
match process_command(&mut stream, &mut session, &config).await? {
|
||||
CommandResult::Continue => {}
|
||||
CommandResult::Quit => break,
|
||||
CommandResult::StartTls => {
|
||||
if let Some(ref acceptor) = config.tls_acceptor {
|
||||
tracing::debug!("Upgrading connection to TLS");
|
||||
let inner = stream.into_inner();
|
||||
match acceptor.clone().accept(inner).await {
|
||||
Ok(tls_stream) => {
|
||||
session.tls_active = true;
|
||||
session.reset();
|
||||
return handle_tls_session(tls_stream, session, config).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("STARTTLS handshake failed: {e}");
|
||||
return Err(io::Error::other(format!("TLS handshake failed: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_tls_connection<S>(stream: S, config: SmtpConfig) -> io::Result<()>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let session = Session::new(config.auth_required, true);
|
||||
handle_tls_session(stream, session, config).await
|
||||
}
|
||||
|
||||
async fn handle_tls_session<S>(
|
||||
stream: S,
|
||||
mut session: Session,
|
||||
config: SmtpConfig,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let mut stream = BufStream::new(stream);
|
||||
loop {
|
||||
match process_command(&mut stream, &mut session, &config).await? {
|
||||
CommandResult::Continue => {}
|
||||
CommandResult::Quit => break,
|
||||
CommandResult::StartTls => {
|
||||
stream.write_all(b"503 TLS already active\r\n").await?;
|
||||
stream.flush().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn process_command<S>(
|
||||
stream: &mut BufStream<S>,
|
||||
session: &mut Session,
|
||||
config: &SmtpConfig,
|
||||
) -> io::Result<CommandResult>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
let mut line = String::new();
|
||||
let bytes_read = match timeout(SMTP_IDLE_TIMEOUT, stream.inner.read_line(&mut line)).await {
|
||||
Ok(res) => res?,
|
||||
Err(_) => return Err(io::Error::new(io::ErrorKind::TimedOut, "Command timeout")),
|
||||
};
|
||||
|
||||
if bytes_read == 0 {
|
||||
return Ok(CommandResult::Quit);
|
||||
}
|
||||
|
||||
let trimmed = line.trim();
|
||||
let cmd = trimmed.to_uppercase();
|
||||
|
||||
match &session.auth_state {
|
||||
AuthState::WaitingForPlain => {
|
||||
verify_plain_auth(trimmed, session, stream).await?;
|
||||
session.auth_state = AuthState::None;
|
||||
return Ok(CommandResult::Continue);
|
||||
}
|
||||
AuthState::WaitingForLoginUsername => {
|
||||
if let Ok(decoded) = BASE64_STANDARD.decode(trimmed) {
|
||||
let username = String::from_utf8_lossy(&decoded).to_string();
|
||||
stream.write_all(b"334 UGFzc3dvcmQ6\r\n").await?;
|
||||
stream.flush().await?;
|
||||
session.auth_state = AuthState::WaitingForLoginPassword(username);
|
||||
} else {
|
||||
stream.write_all(b"501 Cannot decode\r\n").await?;
|
||||
stream.flush().await?;
|
||||
session.auth_state = AuthState::None;
|
||||
}
|
||||
return Ok(CommandResult::Continue);
|
||||
}
|
||||
AuthState::WaitingForLoginPassword(username) => {
|
||||
let username = username.clone();
|
||||
if let Ok(decoded) = BASE64_STANDARD.decode(trimmed) {
|
||||
let password = String::from_utf8_lossy(&decoded);
|
||||
match AccessTokenModel::resolve_user_from_token(&password).await {
|
||||
Ok(user) => {
|
||||
session.authenticated = true;
|
||||
session.user = Some(user);
|
||||
stream
|
||||
.write_all(b"235 Authentication successful\r\n")
|
||||
.await?;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
"SMTP Auth failed for user '{}' (AUTH LOGIN): {:?}",
|
||||
username,
|
||||
error
|
||||
);
|
||||
stream.write_all(b"535 Authentication failed\r\n").await?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stream.write_all(b"501 Cannot decode\r\n").await?;
|
||||
}
|
||||
stream.flush().await?;
|
||||
session.auth_state = AuthState::None;
|
||||
return Ok(CommandResult::Continue);
|
||||
}
|
||||
AuthState::None => {}
|
||||
}
|
||||
|
||||
if cmd.starts_with("EHLO") || cmd.starts_with("HELO") {
|
||||
let mut response = String::from("250-Bichon Hello\r\n");
|
||||
response.push_str("250-SIZE 52428800\r\n"); // 50MB
|
||||
response.push_str("250-8BITMIME\r\n");
|
||||
|
||||
if config.tls_acceptor.is_some() && !session.tls_active {
|
||||
response.push_str("250-STARTTLS\r\n");
|
||||
}
|
||||
|
||||
response.push_str("250-AUTH PLAIN LOGIN\r\n");
|
||||
response.push_str("250 OK\r\n");
|
||||
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
stream.flush().await?;
|
||||
} else if cmd.starts_with("STARTTLS") {
|
||||
if config.tls_acceptor.is_none() {
|
||||
stream.write_all(b"454 TLS not available\r\n").await?;
|
||||
} else if session.tls_active {
|
||||
stream.write_all(b"503 TLS already active\r\n").await?;
|
||||
} else {
|
||||
stream.write_all(b"220 Ready to start TLS\r\n").await?;
|
||||
stream.flush().await?;
|
||||
return Ok(CommandResult::StartTls);
|
||||
}
|
||||
} else if cmd.starts_with("AUTH ") {
|
||||
let parts: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
let mechanism = parts[1].to_uppercase();
|
||||
match mechanism.as_str() {
|
||||
"PLAIN" => {
|
||||
if parts.len() > 2 {
|
||||
verify_plain_auth(parts[2], session, stream).await?;
|
||||
} else {
|
||||
stream.write_all(b"334 \r\n").await?;
|
||||
session.auth_state = AuthState::WaitingForPlain;
|
||||
}
|
||||
}
|
||||
"LOGIN" => {
|
||||
stream.write_all(b"334 VXNlcm5hbWU6\r\n").await?;
|
||||
session.auth_state = AuthState::WaitingForLoginUsername;
|
||||
}
|
||||
_ => {
|
||||
stream.write_all(b"504 Unrecognized auth type\r\n").await?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stream.write_all(b"501 Syntax error\r\n").await?;
|
||||
}
|
||||
} else if cmd.starts_with("MAIL FROM:") {
|
||||
if session.auth_required && !session.authenticated {
|
||||
stream.write_all(b"530 Authentication required\r\n").await?;
|
||||
} else {
|
||||
let addr = extract_address(&trimmed[10..]);
|
||||
let mut allowed = true;
|
||||
if let Some(ref whitelist) = config.whitelist {
|
||||
if !whitelist.is_empty() && !whitelist.contains(&addr) {
|
||||
allowed = false;
|
||||
}
|
||||
}
|
||||
|
||||
if allowed {
|
||||
session.mail_from = Some(addr);
|
||||
stream.write_all(b"250 OK\r\n").await?;
|
||||
} else {
|
||||
stream.write_all(b"550 Sender not allowed\r\n").await?;
|
||||
}
|
||||
}
|
||||
} else if cmd.starts_with("RCPT TO:") {
|
||||
if !session.rcpt_to.is_empty() {
|
||||
stream
|
||||
.write_all(b"452 4.5.3 Too many recipients, try again in a new transaction\r\n")
|
||||
.await?;
|
||||
return Ok(CommandResult::Continue);
|
||||
}
|
||||
|
||||
if session.auth_required && !session.authenticated {
|
||||
stream.write_all(b"530 Authentication required\r\n").await?;
|
||||
} else if session.mail_from.is_none() {
|
||||
stream
|
||||
.write_all(b"503 MAIL FROM required first\r\n")
|
||||
.await?;
|
||||
} else {
|
||||
let addr = extract_address(&trimmed[8..]);
|
||||
println!("DEBUG: SMTP RCPT TO extracted address -> '{}'", addr);
|
||||
let account_result = AccountModel::find_by_email(addr.as_str()).await;
|
||||
|
||||
match account_result {
|
||||
Ok(Some(account)) => {
|
||||
let mut is_allowed = true;
|
||||
if session.auth_required {
|
||||
if let Some(user) = &session.user {
|
||||
let has_perm = ClientContext::check_has_permission(
|
||||
user,
|
||||
Some(account.id),
|
||||
Permission::DATA_SMTP_INGEST,
|
||||
)
|
||||
.await;
|
||||
|
||||
if !has_perm {
|
||||
tracing::warn!(
|
||||
"SMTP: Access denied for User {} to Account <{}>",
|
||||
user.id,
|
||||
addr
|
||||
);
|
||||
stream
|
||||
.write_all(
|
||||
b"554 5.7.1 Access denied: Insufficient permissions\r\n",
|
||||
)
|
||||
.await?;
|
||||
is_allowed = false;
|
||||
}
|
||||
} else {
|
||||
stream
|
||||
.write_all(b"530 5.7.0 Authentication required\r\n")
|
||||
.await?;
|
||||
is_allowed = false;
|
||||
}
|
||||
}
|
||||
|
||||
if is_allowed {
|
||||
session.rcpt_to.push(account);
|
||||
stream.write_all(b"250 OK\r\n").await?;
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
let err = format!("550 5.1.1 <{}>: Bichon account not found\r\n", addr);
|
||||
stream.write_all(err.as_bytes()).await?;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("SMTP: Account query error for {}: {:?}", addr, e);
|
||||
stream
|
||||
.write_all(
|
||||
b"451 4.3.0 Requested action aborted: local error in processing\r\n",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if cmd == "DATA" {
|
||||
if session.auth_required && !session.authenticated {
|
||||
stream.write_all(b"530 Authentication required\r\n").await?;
|
||||
} else if session.mail_from.is_none() {
|
||||
stream
|
||||
.write_all(b"503 MAIL FROM required first\r\n")
|
||||
.await?;
|
||||
} else if session.rcpt_to.is_empty() {
|
||||
stream.write_all(b"503 RCPT TO required first\r\n").await?;
|
||||
} else {
|
||||
stream
|
||||
.write_all(b"354 End data with <CR><LF>.<CR><LF>\r\n")
|
||||
.await?;
|
||||
stream.flush().await?;
|
||||
|
||||
let data = match read_data(&mut stream.inner).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
if e.to_string().contains("552") {
|
||||
let error_msg = format!(
|
||||
"552 5.3.4 Message size exceeds limit of {} bytes ({}MB)\r\n",
|
||||
MAX_MAIL_SIZE,
|
||||
MAX_MAIL_SIZE / 1024 / 1024
|
||||
);
|
||||
stream.write_all(error_msg.as_bytes()).await?;
|
||||
stream.flush().await?;
|
||||
return Ok(CommandResult::Continue);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
match parse_email(&data, session).await {
|
||||
Ok(_) => {
|
||||
stream
|
||||
.write_all(b"250 2.0.0 OK: queued in Bichon\r\n")
|
||||
.await?;
|
||||
tracing::info!(
|
||||
"SMTP: Message accepted and archived for {} recipients",
|
||||
session.rcpt_to.len()
|
||||
);
|
||||
session.reset();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("SMTP: Critical error during parse_email: {:?}", e);
|
||||
stream
|
||||
.write_all(
|
||||
b"451 4.3.0 Error: local error in processing, try again later\r\n",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if cmd == "RSET" {
|
||||
session.reset();
|
||||
stream.write_all(b"250 OK\r\n").await?;
|
||||
} else if cmd == "NOOP" {
|
||||
stream.write_all(b"250 OK\r\n").await?;
|
||||
} else if cmd == "QUIT" {
|
||||
stream.write_all(b"221 Bye\r\n").await?;
|
||||
stream.flush().await?;
|
||||
return Ok(CommandResult::Quit);
|
||||
} else {
|
||||
stream.write_all(b"500 Command not recognized\r\n").await?;
|
||||
}
|
||||
|
||||
stream.flush().await?;
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
|
||||
async fn verify_plain_auth<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
encoded: &str,
|
||||
session: &mut Session,
|
||||
stream: &mut BufStream<S>,
|
||||
) -> io::Result<()> {
|
||||
if let Ok(decoded) = BASE64_STANDARD.decode(encoded.trim()) {
|
||||
let parts: Vec<&[u8]> = decoded.split(|&b| b == 0).collect();
|
||||
if parts.len() >= 3 {
|
||||
let username = String::from_utf8_lossy(parts[1]);
|
||||
let password = String::from_utf8_lossy(parts[2]);
|
||||
|
||||
match AccessTokenModel::resolve_user_from_token(&password).await {
|
||||
Ok(user) => {
|
||||
session.authenticated = true;
|
||||
session.user = Some(user);
|
||||
stream
|
||||
.write_all(b"235 Authentication successful\r\n")
|
||||
.await?;
|
||||
stream.flush().await?;
|
||||
return Ok(());
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!("SMTP Auth failed for user '{}': {:?}", username, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stream.write_all(b"535 Authentication failed\r\n").await?;
|
||||
stream.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_address(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
if let (Some(start), Some(end)) = (s.find('<'), s.find('>')) {
|
||||
return s[start + 1..end].to_string();
|
||||
}
|
||||
s.to_string()
|
||||
}
|
||||
|
||||
async fn read_data<R: AsyncBufReadExt + Unpin>(reader: &mut R) -> io::Result<Vec<u8>> {
|
||||
let mut data = Vec::with_capacity(65536);
|
||||
let mut line = String::new();
|
||||
let mut total_bytes = 0;
|
||||
let line_timeout = Duration::from_secs(30);
|
||||
|
||||
loop {
|
||||
line.clear();
|
||||
let bytes_read = match timeout(line_timeout, reader.read_line(&mut line)).await {
|
||||
Ok(res) => res?,
|
||||
Err(_) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"Data transmission timeout",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
total_bytes += bytes_read;
|
||||
|
||||
if total_bytes > MAX_MAIL_SIZE {
|
||||
tracing::warn!(
|
||||
"SMTP: Message rejected. Size {} bytes exceeds limit of {}MB",
|
||||
total_bytes,
|
||||
MAX_MAIL_SIZE / 1024 / 1024
|
||||
);
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"552 5.3.4 Message size exceeds fixed maximum message size",
|
||||
));
|
||||
}
|
||||
if line.trim() == "." {
|
||||
break;
|
||||
}
|
||||
let content = if line.starts_with("..") {
|
||||
&line[1..]
|
||||
} else {
|
||||
&line
|
||||
};
|
||||
data.extend_from_slice(content.as_bytes());
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
async fn parse_email(data: &[u8], session: &Session) -> BichonResult<()> {
|
||||
let fields = SchemaTools::eml_fields();
|
||||
let rcpt = match session.rcpt_to.first() {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"SMTP: parse_email called with empty recipient list. Skipping processing."
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let mailbox = MailBox {
|
||||
id: create_hash(rcpt.id, "INBOX"),
|
||||
account_id: rcpt.id,
|
||||
name: "INBOX".into(),
|
||||
delimiter: Some("/".to_string()),
|
||||
attributes: vec![Attribute {
|
||||
attr: AttributeEnum::Extension,
|
||||
extension: Some("CreatedByBichon".into()),
|
||||
}],
|
||||
exists: 0,
|
||||
unseen: None,
|
||||
uid_next: None,
|
||||
uid_validity: None,
|
||||
};
|
||||
let mailbox_id = mailbox.id;
|
||||
|
||||
if let Err(e) = MailBox::batch_upsert(&[mailbox]).await {
|
||||
tracing::error!("SMTP: Failed to upsert mailbox for {}: {:?}", rcpt.email, e);
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
let envelope = extract_envelope_from_smtp(data, rcpt.id, mailbox_id).map_err(|e| {
|
||||
tracing::error!(
|
||||
"SMTP: Envelope extraction failed for {}: {:?}",
|
||||
rcpt.email,
|
||||
e
|
||||
);
|
||||
e
|
||||
})?;
|
||||
|
||||
let eml_id = create_hash(rcpt.id, &envelope.0.message_id);
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.add_document(envelope.0.id, envelope)
|
||||
.await;
|
||||
|
||||
EML_INDEX_MANAGER
|
||||
.add_document(
|
||||
eml_id,
|
||||
doc!(
|
||||
fields.f_id => eml_id,
|
||||
fields.f_account_id => rcpt.id,
|
||||
fields.f_mailbox_id => mailbox_id,
|
||||
fields.f_eml => data
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use std::{io, pin::Pin};
|
||||
use tokio::io::{AsyncBufRead, AsyncRead, AsyncWrite, BufReader};
|
||||
|
||||
pub struct BufStream<S> {
|
||||
pub inner: BufReader<S>,
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + AsyncWrite + Unpin> BufStream<S> {
|
||||
pub fn new(stream: S) -> Self {
|
||||
Self {
|
||||
inner: BufReader::new(stream),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> S {
|
||||
self.inner.into_inner()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + Unpin> AsyncBufRead for BufStream<S> {
|
||||
fn poll_fill_buf(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<io::Result<&[u8]>> {
|
||||
Pin::new(&mut self.get_mut().inner).poll_fill_buf(cx)
|
||||
}
|
||||
|
||||
fn consume(self: Pin<&mut Self>, amt: usize) {
|
||||
Pin::new(&mut self.get_mut().inner).consume(amt);
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + Unpin> AsyncRead for BufStream<S> {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().inner).poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: AsyncRead + AsyncWrite + Unpin> AsyncWrite for BufStream<S> {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<io::Result<usize>> {
|
||||
Pin::new(self.get_mut().inner.get_mut()).poll_write(cx, buf)
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<io::Result<()>> {
|
||||
Pin::new(self.get_mut().inner.get_mut()).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<io::Result<()>> {
|
||||
Pin::new(self.get_mut().inner.get_mut()).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
use std::error::Error;
|
||||
|
||||
use lettre::address::Envelope;
|
||||
use lettre::transport::smtp::authentication::{Credentials, Mechanism};
|
||||
use lettre::transport::smtp::client::{Tls, TlsParameters};
|
||||
use lettre::{Message, SmtpTransport, Transport};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_smtp_archiving_flow() {
|
||||
let email = Message::builder()
|
||||
.from("tester@bichon.local".parse().unwrap())
|
||||
.to("archive@bichon.local".parse().unwrap())
|
||||
.subject("Integration Test")
|
||||
.body(String::from("Checking if Bichon saves this!"))
|
||||
.unwrap();
|
||||
|
||||
let envelope = Envelope::new(
|
||||
Some("sender@example.com".parse().unwrap()),
|
||||
vec!["placeholder@example.com".parse().unwrap()], // the email of a bichon account
|
||||
)
|
||||
.unwrap();
|
||||
let mailer = SmtpTransport::builder_dangerous("127.0.0.1")
|
||||
.port(2525)
|
||||
.tls(Tls::None)
|
||||
.build();
|
||||
|
||||
let result = mailer.send_raw(&envelope, &email.formatted());
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"SMTP delivery should succeed, got: {:?}",
|
||||
result.err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bichon_smtp_logic() -> Result<(), Box<dyn Error>> {
|
||||
let smtp_host = "127.0.0.1";
|
||||
let smtp_port = 2525;
|
||||
|
||||
println!("--- Testing STARTTLS Upgrade ---");
|
||||
|
||||
let tls_parameters = TlsParameters::builder(smtp_host.to_string())
|
||||
.dangerous_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
|
||||
let mailer = SmtpTransport::starttls_relay(smtp_host)?
|
||||
.port(smtp_port)
|
||||
.tls(Tls::Required(tls_parameters))
|
||||
.authentication(vec![Mechanism::Login, Mechanism::Plain])
|
||||
.credentials(Credentials::new(
|
||||
"test_user".to_string(),
|
||||
"hP1Z4ZBs4IjdXtjbImFoX9kM".to_string(),
|
||||
))
|
||||
.build();
|
||||
|
||||
// If RCPT TO is not explicitly specified in the envelope, the addresses in the 'To' header
|
||||
// will be treated as envelope recipients. Bichon enforces a single-recipient policy per
|
||||
// transaction; if multiple recipients are detected, it will reject with:
|
||||
// "452 4.5.3 Too many recipients, try again in a new transaction".
|
||||
let email = Message::builder()
|
||||
.from("sender@bichon.com".parse()?)
|
||||
.to("placeholder@example.com".parse()?)
|
||||
.subject("TLS Test")
|
||||
.body(String::from("Hello Bichon with TLS!"))?;
|
||||
|
||||
let result = mailer.send(&email);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"STARTTLS encryption or Auth failed: {:?}",
|
||||
result.err()
|
||||
);
|
||||
println!("SUCCESS: TLS upgrade and mail delivery worked.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// Copyright (c) 2025 rustmailer.com (https://rustmailer.com)
|
||||
//
|
||||
// This file is part of the Bichon Email Archiving Project
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
use rcgen::generate_simple_self_signed;
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
|
||||
use rustls::ServerConfig;
|
||||
use std::io::{self, BufReader, Error, ErrorKind};
|
||||
use std::sync::Arc;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
|
||||
use crate::modules::settings::cli::SETTINGS;
|
||||
|
||||
pub async fn create_acceptor() -> io::Result<TlsAcceptor> {
|
||||
let (certs, key) = if let (Some(key_path), Some(cert_path)) = (
|
||||
&SETTINGS.bichon_smtp_tls_key_path,
|
||||
&SETTINGS.bichon_smtp_tls_cert_path,
|
||||
) {
|
||||
load_certs_from_files(key_path, cert_path).await?
|
||||
} else {
|
||||
generate_self_signed()?
|
||||
};
|
||||
|
||||
let config = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)
|
||||
.map_err(|e| Error::new(ErrorKind::InvalidData, e))?;
|
||||
|
||||
Ok(TlsAcceptor::from(Arc::new(config)))
|
||||
}
|
||||
|
||||
async fn load_certs_from_files(
|
||||
key_path: &str,
|
||||
cert_path: &str,
|
||||
) -> io::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
|
||||
let mut key_file = File::open(key_path).await?;
|
||||
let mut key_data = Vec::new();
|
||||
key_file.read_to_end(&mut key_data).await?;
|
||||
|
||||
let mut cert_file = File::open(cert_path).await?;
|
||||
let mut cert_data = Vec::new();
|
||||
cert_file.read_to_end(&mut cert_data).await?;
|
||||
|
||||
let certs: Vec<CertificateDer<'static>> =
|
||||
rustls_pemfile::certs(&mut BufReader::new(cert_data.as_slice()))
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
|
||||
let key = rustls_pemfile::private_key(&mut BufReader::new(key_data.as_slice()))?
|
||||
.ok_or_else(|| Error::new(ErrorKind::InvalidData, "No private key found"))?;
|
||||
|
||||
Ok((certs, key))
|
||||
}
|
||||
|
||||
fn generate_self_signed() -> io::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
|
||||
let subject_alt_names = vec!["localhost".to_string(), "127.0.0.1".to_string()];
|
||||
let key = generate_simple_self_signed(subject_alt_names).map_err(Error::other)?;
|
||||
|
||||
let cert_der = CertificateDer::from(key.cert.der().to_vec());
|
||||
let key_der = PrivateKeyDer::try_from(key.signing_key.serialize_der())
|
||||
.map_err(|e| Error::new(ErrorKind::InvalidData, e))?;
|
||||
|
||||
Ok((vec![cert_der], key_der))
|
||||
}
|
||||
@@ -116,6 +116,9 @@ impl Permission {
|
||||
/// Authorization requires checking access to the target account_id.
|
||||
pub const DATA_IMPORT_BATCH: &str = "data:import:batch";
|
||||
|
||||
/// Allow SMTP ingestion into SPECIFIC accounts.
|
||||
pub const DATA_SMTP_INGEST: &str = "data:smtp:ingest";
|
||||
|
||||
pub fn global_permissions() -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
(
|
||||
@@ -194,6 +197,10 @@ impl Permission {
|
||||
Self::DATA_IMPORT_BATCH,
|
||||
"Import external EML/PST data into authorized accounts.",
|
||||
),
|
||||
(
|
||||
Self::DATA_SMTP_INGEST,
|
||||
"Receive and archive emails via SMTP for authorized accounts.",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ impl BuiltinRole {
|
||||
Permission::DATA_DELETE,
|
||||
Permission::DATA_EXPORT_BATCH,
|
||||
Permission::DATA_IMPORT_BATCH,
|
||||
Permission::DATA_SMTP_INGEST,
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
|
||||
@@ -40,6 +40,7 @@ export function getPermissions(t: (key: string) => string) {
|
||||
{ label: t('permission.data.delete'), value: 'data:delete' },
|
||||
{ label: t('permission.data.export_batch'), value: 'data:export:batch' },
|
||||
{ label: t('permission.data.import_batch'), value: 'data:import:batch' },
|
||||
{ label: t('permission.data.smtp_ingest'), value: 'data:smtp:ingest' },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ function getAccountCategories(t: (key: string) => string) {
|
||||
'data:delete',
|
||||
'data:export:batch',
|
||||
'data:import:batch',
|
||||
'data:smtp:ingest',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -86,6 +86,7 @@ const CATEGORY_MAP: Record<'Global' | 'Account', { titleKey: string; keys: strin
|
||||
'data:delete',
|
||||
'data:export:batch',
|
||||
'data:import:batch',
|
||||
'data:smtp:ingest',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -58,7 +58,7 @@ export function PermissionsDialog({ currentRow, open, onOpenChange }: Props) {
|
||||
account: [
|
||||
{
|
||||
titleKey: "roles.categories.account_resource",
|
||||
keys: ["account:manage", "account:read_details", "data:read", "data:manage", "data:raw:download", "data:delete", "data:export:batch", "data:import:batch"]
|
||||
keys: ["account:manage", "account:read_details", "data:read", "data:manage", "data:raw:download", "data:delete", "data:export:batch", "data:import:batch", "data:smtp:ingest"]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "حذف رسائل البريد الإلكتروني من جميع الحسابات",
|
||||
"export_batch": "تصدير دفعي للبيانات المخصصة",
|
||||
"export_batch_all": "تصدير دفعي لجميع البيانات",
|
||||
"import_batch": "استيراد دفعي لبيانات البريد الإلكتروني"
|
||||
"import_batch": "استيراد دفعي لبيانات البريد الإلكتروني",
|
||||
"smtp_ingest": "استيعاب رسائل البريد الإلكتروني عبر بروتوكول SMTP"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "Slette e-mails fra alle konti",
|
||||
"export_batch": "Batch-eksportere tildelte data",
|
||||
"export_batch_all": "Batch-eksportere alle data",
|
||||
"import_batch": "Batch-importere e-maildata"
|
||||
"import_batch": "Batch-importere e-maildata",
|
||||
"smtp_ingest": "Indsend e-mails via SMTP-protokol"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "E-Mails aller Konten löschen",
|
||||
"export_batch": "Zugewiesene Daten stapelweise exportieren",
|
||||
"export_batch_all": "Alle Daten stapelweise exportieren",
|
||||
"import_batch": "E-Mail-Daten stapelweise importieren"
|
||||
"import_batch": "E-Mail-Daten stapelweise importieren",
|
||||
"smtp_ingest": "E-Mails über das SMTP-Protokoll aufnehmen"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "Delete Emails from All Accounts",
|
||||
"export_batch": "Batch Export Assigned Data",
|
||||
"export_batch_all": "Batch Export All Data",
|
||||
"import_batch": "Batch Import Email Data"
|
||||
"import_batch": "Batch Import Email Data",
|
||||
"smtp_ingest": "Ingest Emails via SMTP Protocol"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "Eliminar correos de todas las cuentas",
|
||||
"export_batch": "Exportar datos asignados por lotes",
|
||||
"export_batch_all": "Exportar todos los datos por lotes",
|
||||
"import_batch": "Importar datos de correo por lotes"
|
||||
"import_batch": "Importar datos de correo por lotes",
|
||||
"smtp_ingest": "Ingerir correos electrónicos a través del protocolo SMTP"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "Kaikkien tilien sähköpostien poistaminen",
|
||||
"export_batch": "Tietojen erävienti",
|
||||
"export_batch_all": "Kaikkien tietojen erävienti",
|
||||
"import_batch": "Sähköpostitietojen erätuonti"
|
||||
"import_batch": "Sähköpostitietojen erätuonti",
|
||||
"smtp_ingest": "Vastaanota sähköposteja SMTP-protokollan kautta"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "Supprimer les e-mails de tous les comptes",
|
||||
"export_batch": "Exporter les données attribuées par lot",
|
||||
"export_batch_all": "Exporter toutes les données par lot",
|
||||
"import_batch": "Importer des données e-mail par lot"
|
||||
"import_batch": "Importer des données e-mail par lot",
|
||||
"smtp_ingest": "Ingérer des e-mails via le protocole SMTP"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "Eliminare le email di tutti gli account",
|
||||
"export_batch": "Esportare i dati assegnati in batch",
|
||||
"export_batch_all": "Esportare tutti i dati in batch",
|
||||
"import_batch": "Importare dati email in batch"
|
||||
"import_batch": "Importare dati email in batch",
|
||||
"smtp_ingest": "Acquisire e-mail tramite protocollo SMTP"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "すべてのアカウントのメールを削除",
|
||||
"export_batch": "割り当てデータを一括エクスポート",
|
||||
"export_batch_all": "すべてのデータを一括エクスポート",
|
||||
"import_batch": "メールデータを一括インポート"
|
||||
"import_batch": "メールデータを一括インポート",
|
||||
"smtp_ingest": "SMTPプロトコル経由でメールを取り込む"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "모든 계정의 이메일 삭제",
|
||||
"export_batch": "할당 데이터 일괄 내보내기",
|
||||
"export_batch_all": "모든 데이터 일괄 내보내기",
|
||||
"import_batch": "이메일 데이터 일괄 가져오기"
|
||||
"import_batch": "이메일 데이터 일괄 가져오기",
|
||||
"smtp_ingest": "SMTP 프로토콜을 통해 이메일 수집"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "E-mails van alle accounts verwijderen",
|
||||
"export_batch": "Toegewezen gegevens batchgewijs exporteren",
|
||||
"export_batch_all": "Alle gegevens batchgewijs exporteren",
|
||||
"import_batch": "E-mailgegevens batchgewijs importeren"
|
||||
"import_batch": "E-mailgegevens batchgewijs importeren",
|
||||
"smtp_ingest": "E-mails opnemen via het SMTP-protocol"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "Slette e-poster fra alle kontoer",
|
||||
"export_batch": "Batch-eksportere tildelte data",
|
||||
"export_batch_all": "Batch-eksportere alle data",
|
||||
"import_batch": "Batch-importere e-postdata"
|
||||
"import_batch": "Batch-importere e-postdata",
|
||||
"smtp_ingest": "Hent e-post via SMTP-protokoll"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "Usuwanie wiadomości e-mail ze wszystkich kont",
|
||||
"export_batch": "Eksport zbiorczy przypisanych danych",
|
||||
"export_batch_all": "Eksport zbiorczy wszystkich danych",
|
||||
"import_batch": "Import zbiorczy danych e-mail"
|
||||
"import_batch": "Import zbiorczy danych e-mail",
|
||||
"smtp_ingest": "Pobieranie wiadomości e-mail za pośrednictwem protokołu SMTP"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "Excluir e-mails de todas as contas",
|
||||
"export_batch": "Exportar dados atribuídos em lote",
|
||||
"export_batch_all": "Exportar todos os dados em lote",
|
||||
"import_batch": "Importar dados de e-mail em lote"
|
||||
"import_batch": "Importar dados de e-mail em lote",
|
||||
"smtp_ingest": "Ingerir e-mails via protocolo SMTP"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "Удаление писем всех аккаунтов",
|
||||
"export_batch": "Пакетный экспорт назначенных данных",
|
||||
"export_batch_all": "Пакетный экспорт всех данных",
|
||||
"import_batch": "Пакетный импорт почтовых данных"
|
||||
"import_batch": "Пакетный импорт почтовых данных",
|
||||
"smtp_ingest": "Прием электронной почты через протокол SMTP"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "Ta bort e-postmeddelanden från alla konton",
|
||||
"export_batch": "Batch-exportera tilldelad data",
|
||||
"export_batch_all": "Batch-exportera all data",
|
||||
"import_batch": "Batch-importera e-postdata"
|
||||
"import_batch": "Batch-importera e-postdata",
|
||||
"smtp_ingest": "Hämta e-post via SMTP-protokoll"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "刪除所有帳戶的郵件",
|
||||
"export_batch": "批次匯出指派帳戶的資料",
|
||||
"export_batch_all": "批次匯出所有資料",
|
||||
"import_batch": "批次匯入郵件資料"
|
||||
"import_batch": "批次匯入郵件資料",
|
||||
"smtp_ingest": "透過 SMTP 協定歸檔郵件"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
@@ -1132,7 +1132,8 @@
|
||||
"delete_all": "删除所有账户的邮件",
|
||||
"export_batch": "批量导出分配账户的数据",
|
||||
"export_batch_all": "批量导出所有数据",
|
||||
"import_batch": "批量导入邮件数据"
|
||||
"import_batch": "批量导入邮件数据",
|
||||
"smtp_ingest": "通过 SMTP 协议归档邮件"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
|
||||
Reference in New Issue
Block a user