diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f284342 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: check · test · clippy · fmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all --check + - run: cargo clippy --workspace --all-targets --all-features + - run: cargo test --workspace --all-features + + licences: + name: licence policy (ADR 0002) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check licenses bans sources advisories + + # Native aarch64 build on spark-1. Oracle Cloud's free tier is Ampere A1 + # (aarch64), so the release artefact must be aarch64 — and spark-1 (GB10, + # 20 cores, 121 GB) builds it natively rather than cross-compiling. + aarch64: + name: release build · aarch64 (spark-1) + runs-on: [self-hosted, linux, ARM64, spark-1] + if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' + steps: + - uses: actions/checkout@v5 + - uses: Swatinem/rust-cache@v2 + - run: cargo build --release --workspace + - uses: actions/upload-artifact@v4 + with: + name: openmail-aarch64 + path: target/release/openmail + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 512b348..432a50f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ -/bin/ -*.exe +/target +**/*.rs.bk .env -.DS_Store +.env.* +!.env.example +*.pem +*.key +/data diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0f66415 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3451 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashify" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26" +dependencies = [ + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "aws-lc-rs", + "bitflags", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni", + "lru-cache", + "parking_lot", + "rand 0.10.2", + "ring", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "thiserror", + "time", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "aws-lc-rs", + "bitflags", + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.2", + "ring", + "rustls-pki-types", + "thiserror", + "time", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.2", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +dependencies = [ + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[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", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[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.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "mail-auth" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faa246141b9a9194be268605d9db7228784a0eb347d155eeaa223181037693d0" +dependencies = [ + "aws-lc-rs", + "flate2", + "getrandom 0.2.17", + "getrandom 0.4.3", + "hashify", + "hickory-resolver", + "idna", + "mail-builder 0.4.4", + "mail-parser", + "quick-xml", + "rand 0.8.8", + "rsa", + "rustls-pki-types", + "serde", + "serde_json", + "similar", + "zip", +] + +[[package]] +name = "mail-builder" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b" +dependencies = [ + "gethostname", +] + +[[package]] +name = "mail-builder" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c942e8a4b83f9351236c1e531ea9fa0237913d63c7fc36818430e0128a1ddf3" +dependencies = [ + "gethostname", +] + +[[package]] +name = "mail-dane" +version = "0.1.0" +dependencies = [ + "hickory-resolver", + "rustls", + "sha2", + "thiserror", + "tracing", +] + +[[package]] +name = "mail-dsn" +version = "0.1.0" +dependencies = [ + "chrono", + "mail-builder 0.5.0", + "mail-parser", + "thiserror", +] + +[[package]] +name = "mail-mta-sts" +version = "0.1.0" +dependencies = [ + "hickory-resolver", + "serde", + "thiserror", + "tracing", +] + +[[package]] +name = "mail-parser" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b3a9080c1fb8190e232df37a10aa1b3d6b08be084e537069913f025e0ce86c5" +dependencies = [ + "encoding_rs", + "hashify", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openmail" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "openmail-api", + "openmail-core", + "openmail-mcp", + "openmail-relay", + "openmail-smtpd", + "openmail-store", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "openmail-api" +version = "0.1.0" +dependencies = [ + "axum", + "openmail-core", + "openmail-store", + "serde", + "serde_json", + "thiserror", + "tokio", + "tower-http", + "tracing", + "uuid", +] + +[[package]] +name = "openmail-core" +version = "0.1.0" +dependencies = [ + "chrono", + "mail-parser", + "serde", + "serde_json", + "thiserror", + "uuid", +] + +[[package]] +name = "openmail-guard" +version = "0.1.0" +dependencies = [ + "hickory-resolver", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "openmail-junk" +version = "0.1.0" +dependencies = [ + "serde", + "sha2", + "thiserror", +] + +[[package]] +name = "openmail-mcp" +version = "0.1.0" +dependencies = [ + "openmail-core", + "openmail-store", + "serde", + "serde_json", + "thiserror", + "tokio", +] + +[[package]] +name = "openmail-relay" +version = "0.1.0" +dependencies = [ + "hickory-resolver", + "mail-auth", + "mail-builder 0.5.0", + "mail-dane", + "mail-mta-sts", + "serde", + "smtp-proto", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "openmail-smtpd" +version = "0.1.0" +dependencies = [ + "mail-auth", + "mail-parser", + "openmail-guard", + "rustls", + "smtp-proto", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "openmail-store" +version = "0.1.0" +dependencies = [ + "chrono", + "openmail-core", + "serde_json", + "sqlx", + "thiserror", + "tokio", + "uuid", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "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 = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +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 = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987" +dependencies = [ + "bstr", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +dependencies = [ + "serde", +] + +[[package]] +name = "smtp-proto" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e365d0fff6ae5036364ab62f236fa669ce05acbd5878df32d3755d48cfc1491b" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.8", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.8", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[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 = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "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.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[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 = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0e6f662 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,78 @@ +[workspace] +resolver = "3" +members = [ + # Tier 1 — standalone, publishable to crates.io. No openmail-* dependencies. + "crates/mail-dane", + "crates/mail-mta-sts", + "crates/mail-dsn", + # Tier 2 — OpenMail mail engine. + "crates/openmail-guard", + "crates/openmail-junk", + "crates/openmail-smtpd", + "crates/openmail-relay", + # Tier 3 — the agent-native layer. The product. + "crates/openmail-core", + "crates/openmail-store", + "crates/openmail-api", + "crates/openmail-mcp", + "crates/openmail", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.90" +license = "Apache-2.0" +repository = "https://github.com/karti-ai/openmail" +homepage = "https://openmail.karti.ai" +authors = ["Karti Tripathi"] + +[workspace.dependencies] +# --- internal --- +mail-dane = { version = "0.1.0", path = "crates/mail-dane" } +mail-mta-sts = { version = "0.1.0", path = "crates/mail-mta-sts" } +mail-dsn = { version = "0.1.0", path = "crates/mail-dsn" } +openmail-guard = { version = "0.1.0", path = "crates/openmail-guard" } +openmail-junk = { version = "0.1.0", path = "crates/openmail-junk" } +openmail-smtpd = { version = "0.1.0", path = "crates/openmail-smtpd" } +openmail-relay = { version = "0.1.0", path = "crates/openmail-relay" } +openmail-core = { version = "0.1.0", path = "crates/openmail-core" } +openmail-store = { version = "0.1.0", path = "crates/openmail-store" } +openmail-api = { version = "0.1.0", path = "crates/openmail-api" } +openmail-mcp = { version = "0.1.0", path = "crates/openmail-mcp" } + +# --- third party (all Apache-2.0 or MIT; see NOTICE) --- +mail-parser = { version = "0.11", features = ["full_encoding"] } +mail-builder = "0.5" +mail-auth = { version = "0.12", features = ["generate"] } +smtp-proto = "0.2" +hickory-resolver = { version = "0.26", features = ["dnssec-ring"] } + +tokio = { version = "1", features = ["full"] } +axum = "0.8" +tower-http = { version = "0.6", features = ["trace", "limit"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json", "migrate"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +uuid = { version = "1", features = ["v7", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4", features = ["derive", "env"] } +rustls = "0.23" +sha2 = "0.10" +base64 = "0.22" + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "warn", priority = -1 } + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true diff --git a/LICENSE b/LICENSE index ac8c226..d645695 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,202 @@ -MIT License -Copyright (c) 2026 Karti Tripathi and the OpenMail contributors + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + 1. Definitions. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile deleted file mode 100644 index 0644dbd..0000000 --- a/Makefile +++ /dev/null @@ -1,25 +0,0 @@ -.PHONY: build run spike test tidy migrate fmt vet - -build: - go build -o bin/openmail ./cmd/openmail - -run: build - ./bin/openmail serve - -spike: - go run ./spike/mimecheck - -migrate: build - ./bin/openmail migrate - -test: - go test ./... - -tidy: - go mod tidy - -fmt: - go fmt ./... - -vet: - go vet ./... diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..c36ce33 --- /dev/null +++ b/NOTICE @@ -0,0 +1,29 @@ +OpenMail +Copyright 2026 Karti Tripathi + +This product includes software developed at OpenMail (https://openmail.karti.ai). + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this software except in compliance with the License. You may obtain a copy of +the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +------------------------------------------------------------------------------ +Third-party dependencies +------------------------------------------------------------------------------ + +OpenMail links the following third-party Rust crates. All are used under +permissive licenses compatible with Apache-2.0. No GPL, LGPL, or AGPL code is +linked into any OpenMail binary or library. + + mail-parser Apache-2.0 OR MIT Stalwart Labs MIME parsing + mail-builder Apache-2.0 OR MIT Stalwart Labs RFC 5322 construction + mail-auth Apache-2.0 OR MIT Stalwart Labs DKIM / SPF / DMARC / ARC + smtp-proto Apache-2.0 OR MIT Stalwart Labs SMTP wire protocol + hickory-resolver Apache-2.0 OR MIT Hickory DNS DNS + DNSSEC + +OpenMail does NOT incorporate any code from the Stalwart mail server itself +(crates/* in stalwartlabs/stalwart), which is AGPL-3.0-only OR LicenseRef-SEL. +Only the separately published, permissively licensed primitive crates above are +used. See docs/adr/0003-own-crates.md. diff --git a/README.md b/README.md index adcfa91..9390e67 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,140 @@ -# OpenMail +

OpenMail

-**An agent-native, self-hosted mail server.** One Go binary that gives an AI agent its own real -email address — receive, parse, thread, search, and send actual SMTP mail on a box you control — -behind a clean REST API and an MCP server. +**An agent-native, self-hosted mail server, written in Rust.** -Think "AgentMail, but self-hosted and MIT-licensed." OpenMail embeds the battle-tested mail -internals of [Mox](https://github.com/mjl-/mox) (also MIT) for the hard, correctness-critical -plumbing — DKIM, SPF/DMARC, DANE + MTA-STS secure delivery, real-world MIME parsing, spam -filtering — and layers a native, agent-shaped data model (Postgres + object storage) and API on top. +One binary that gives an AI agent its own real email address — receive, parse, +thread, search and send actual SMTP mail on infrastructure you control — behind +a clean REST API and an MCP server. Humans and agents are both first-class +users. -Mail I/O is **pluggable**: start in minutes against a relay (SES/Postmark/Resend) or your existing -mailbox (IMAP/SMTP), and graduate to a fully self-hosted, in-process SMTP engine when you want to own -the whole stack. The in-process engine is the part nobody else ships — the only comparable project, -[agenticmail](https://github.com/agenticmail/agenticmail), runs a Stalwart (AGPL) mail server in a -Docker sidecar; OpenMail is **one static binary, fully MIT, no sidecar.** +[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE) +[![Status](https://img.shields.io/badge/status-v0.1%20WIP-orange)](./docs/adr/0004-milestones.md) -> **Status: early WIP, private during initial build.** Will be released MIT-licensed and public. -> Designed only from public RFCs and public API surfaces — nothing proprietary. +> **Status: v0.1, work in progress, not yet released.** The workspace compiles +> and the domain model is taking shape; it does not yet send or receive mail. +> Follow [`docs/adr/0004-milestones.md`](./docs/adr/0004-milestones.md). -License: **MIT** — see [LICENSE](./LICENSE). Builds on Mox (MIT) and the `emersion/go-*` mail -libraries. See **[ARCHITECTURE.md](./ARCHITECTURE.md)** for the design of record. +--- -## Why +## Why this exists -The valuable, hard part of an agent-mailbox product is not the API — it's the mail plumbing: -receiving over SMTP/MX, *sending with real deliverability* (SPF/DKIM/DMARC, DANE/MTA-STS, IP -reputation), parsing messy MIME, threading, and storage. Hosted products (AgentMail and similar) -solve this well but are closed and run on someone else's infrastructure. OpenMail's bet: you can -**embed** an existing MIT-licensed, production-grade Go mail stack instead of rebuilding it, and -spend your effort on the part nobody has done well — the **agent-native** layer. +The hard part of an agent-mailbox product was never the API. It is the mail +plumbing: receiving over SMTP/MX, sending with real deliverability +(SPF/DKIM/DMARC, DANE/MTA-STS, IP reputation), parsing genuinely broken MIME, +threading, and storage. + +Hosted agent-mail products solve this well and run on someone else's +infrastructure, closed. The self-hostable mail servers that exist — +Postfix+Dovecot, iRedMail, Stalwart — solve the plumbing but have no notion of +an agent: no per-agent inbox provisioning, no threads as API resources, no MCP, +no way for an agent to own a mailbox. + +**OpenMail is the intersection nobody occupies: agent-native, self-hostable, +and permissively licensed.** ## What makes it agent-native -- **Persistent inboxes as first-class API resources**, provisioned in one call. -- **Structured threads**, not raw IMAP — `In-Reply-To`/`References` stitched into conversations. -- **`extracted_text`** — reply content with quoted history stripped, so an agent reads the new part. -- **MCP server** — an agent (Claude Code, etc.) owns and operates its mailbox directly as tools. -- **Webhooks + WebSocket** `message.received` events — agents react to mail in real time. -- **AgentMail-API-shaped** REST where reasonable, so existing tooling points at a self-hosted base URL. +- **Inboxes are API resources**, provisioned in one call — not Unix accounts. +- **Threads are first-class**, stitched from `In-Reply-To`/`References`. An + agent asks for a conversation, not a folder listing. +- **`extracted_text`** — the reply with quoted history stripped. An agent that + reads full bodies re-reads the whole thread every turn and burns its context + window on text it already has. +- **MCP server** — an agent owns and operates its own mailbox as tools. +- **Webhooks + WebSocket** `message.received` events. Push, not poll. +- **Humans too** — standard IMAP/SMTP access is a first-class goal, not an + afterthought, so a person can point Apple Mail or Thunderbird at the same + mailbox an agent is driving. -## Goals +## Why Rust, and why our own crates -- **Self-hostable** in one `docker compose up` on a single VPS; scales to a fleet later. -- **Deliverability taken seriously** — self-host SMTP send with DKIM + DANE + MTA-STS via Mox's - delivery stack, *or* a relay backend (SES/Postmark/Resend) for inbox placement on day one. -- **Single static Go binary** with subcommands; Postgres + S3-compatible object store as the only deps. -- **Genuinely MIT** — every embedded dependency is MIT/BSD; no GPL/AGPL anywhere in the tree. +Two implementations of the mail plumbing exist in a permissive licence: Mox +(MIT, Go) and — for the primitives only — Stalwart's published crates +(Apache-2.0/MIT, Rust). Stalwart's *server* is AGPL-3.0, which is why nobody +has shipped a permissively licensed Rust mail server. -## Non-goals (for v1) +We are building one. See [`docs/adr/0001-rust.md`](./docs/adr/0001-rust.md) for +the decision and its honest costs. -- A hosted multi-tenant SaaS. OpenMail is self-host-first (multi-tenant `pods` exist, but you run it). -- A full webmail UI. The product is the API + MCP; humans use their own client. -- Beating a mature provider's deliverability on day one — self-host IP reputation takes warmup + time; - the relay backend exists for exactly that gap. +Concretely, this means writing what the Rust ecosystem does not have. At the +time of writing, **`dane` and `mta-sts` do not exist on crates.io at all** — +Stalwart keeps its implementations inside AGPL server crates. Ours ship +standalone and permissive, so any Rust mail project can use them. + +## The workspace + +Twelve crates in three tiers. Tier 1 is published to crates.io as a +contribution to the Rust mail ecosystem and depends on nothing else here. + +### Tier 1 — standalone, publishable + +| Crate | What | Prior art in Rust | +|---|---|---| +| [`mail-dane`](./crates/mail-dane) | DANE / TLSA verification for SMTP (RFC 7672) | **none — first permissive implementation** | +| [`mail-mta-sts`](./crates/mail-mta-sts) | MTA-STS policy discovery, fetch, parse, cache (RFC 8461) | **none — first permissive implementation** | +| [`mail-dsn`](./crates/mail-dsn) | Delivery Status Notifications (RFC 3464) | none | + +### Tier 2 — the mail engine + +| Crate | What | +|---|---| +| [`openmail-smtpd`](./crates/openmail-smtpd) | Inbound SMTP: session state machine, STARTTLS, AUTH, PIPELINING | +| [`openmail-relay`](./crates/openmail-relay) | Outbound: smarthost relays (SES, Oracle, generic) and direct-to-MX | +| [`openmail-guard`](./crates/openmail-guard) | Abuse gate: iprev, DNSBL, rate limiting | +| [`openmail-junk`](./crates/openmail-junk) | Per-inbox Bayesian spam classification | + +### Tier 3 — the agent-native layer (the product) + +| Crate | What | +|---|---| +| [`openmail-core`](./crates/openmail-core) | Domain model, threading, quote-stripping. No I/O. | +| [`openmail-store`](./crates/openmail-store) | Postgres metadata + S3-compatible blobs | +| [`openmail-api`](./crates/openmail-api) | The v0 REST API | +| [`openmail-mcp`](./crates/openmail-mcp) | MCP server | +| [`openmail`](./crates/openmail) | The binary: `serve`, `smtpd`, `sender`, `mcp`, `migrate` | + +### Third-party + +`mail-parser`, `mail-builder`, `mail-auth` (DKIM/DKIM2/SPF/DMARC/ARC), +`smtp-proto` — all Apache-2.0 OR MIT, all from Stalwart Labs' separately +published primitive crates — plus `hickory-resolver` for DNS and DNSSEC. + +**No AGPL, GPL, or LGPL code is linked into any OpenMail binary.** We use none +of the Stalwart *server*. See [`NOTICE`](./NOTICE) and +[`docs/adr/0003-own-crates.md`](./docs/adr/0003-own-crates.md). + +## Sending: bring your own reputation, or build your own + +Outbound sits behind one interface with two paths: + +- **Relay** — SES, Oracle Cloud Email Delivery, SendGrid, Postmark, Resend, or + any smarthost. Rents someone else's IP reputation; inbox placement on day + one. Providers are declarative data, not special cases — + [`crates/openmail-relay/src/providers.rs`](./crates/openmail-relay/src/providers.rs). +- **Direct-to-MX** — we resolve MX and deliver ourselves, with MTA-STS and DANE + enforced. Our reputation, our control, and a months-long IP warmup. + +Receiving is always ours. + +> ⚠️ **Oracle Cloud blocks outbound TCP/25** for tenancies created after +> 2021-06-23. Inbound :25 is unaffected. So on OCI you *receive* directly and +> *relay* outbound on 587 — direct-to-MX is not possible there at all. +> [`docs/adr/0005-oracle-cloud.md`](./docs/adr/0005-oracle-cloud.md). + +## Build + +```bash +cargo check --workspace # ~21s cold on a Ryzen 7 5800X +cargo test --workspace +cargo clippy --workspace --all-targets # zero warnings is the gate +``` + +`unsafe_code = "forbid"` across the workspace. This code parses hostile input +from the open internet on port 25; there is no exception worth the risk. + +## Licence + +**Apache-2.0.** Permissive on purpose: the point is that other people can build +commercial products on top of this, including ones that compete with anything +we might host later. See [`docs/adr/0002-apache-2.md`](./docs/adr/0002-apache-2.md) +for why Apache-2.0 rather than MIT or AGPL. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..696f6bb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security policy + +OpenMail runs a parser on port 25, exposed to the open internet, with its +source published. That is the same position Postfix and Mox are in, and it is +safe only with a real disclosure process. This is ours. + +## Reporting + +**Do not open a public issue for a security bug.** + +Use GitHub's [private vulnerability reporting](https://github.com/karti-ai/openmail/security/advisories/new), +or email the maintainer. We will acknowledge within 72 hours. + +## Scope — what we consider a vulnerability + +- Anything reachable pre-authentication on the SMTP listener. +- MIME parsing that panics, hangs, or allocates unboundedly on crafted input. +- **A silent downgrade of a security property**: DANE or MTA-STS reporting + success where the policy was not actually satisfied, or a policy that should + have been enforced being skipped. These are the highest-severity class in + this codebase precisely because they do not look like failures. +- Cross-tenant (`pod`) data access. +- Authentication or scope bypass in the REST or MCP surfaces — especially an + MCP tool reaching a credential route (see `openmail_mcp::Exposure`). + +## Not in scope + +- Deliverability problems (mail landing in spam). +- Missing rate limits on an endpoint behind authentication, unless it is + amplification. +- Reports from automated scanners with no demonstrated impact. diff --git a/cmd/openmail/main.go b/cmd/openmail/main.go deleted file mode 100644 index 18220a4..0000000 --- a/cmd/openmail/main.go +++ /dev/null @@ -1,154 +0,0 @@ -// Command openmail is the single OpenMail binary. Subcommands map to the roles -// in ARCHITECTURE.md §1: serve (HTTP API + MCP), smtpd (inbound :25), sender -// (outbound), migrate (apply DB schema). smtpd/sender are scaffolded for later -// milestones. -package main - -import ( - "context" - "errors" - "fmt" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - "github.com/karti-ai/openmail/internal/api" - "github.com/karti-ai/openmail/internal/config" - "github.com/karti-ai/openmail/internal/core" - "github.com/karti-ai/openmail/internal/mail" - "github.com/karti-ai/openmail/internal/store" -) - -const version = "0.0.1-dev" - -func main() { - if len(os.Args) < 2 { - usage() - os.Exit(2) - } - cmd := os.Args[1] - cfg := config.Load() - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - - var err error - switch cmd { - case "serve": - err = runServe(ctx, cfg) - case "migrate": - err = runMigrate(ctx, cfg) - case "smtpd": - err = fmt.Errorf("smtpd: not yet implemented (milestone 2: go-smtp + mox verify/parse)") - case "sender": - err = fmt.Errorf("sender: not yet implemented (milestone 3: relay; milestone 5: self-host SMTP)") - case "version", "-v", "--version": - fmt.Println("openmail", version) - default: - usage() - os.Exit(2) - } - if err != nil { - fmt.Fprintln(os.Stderr, "error:", err) - os.Exit(1) - } -} - -func usage() { - fmt.Fprintf(os.Stderr, `openmail %s — agent-native, self-hosted mail server - -usage: openmail - -commands: - serve start the HTTP API (and MCP) server - migrate apply database migrations - smtpd inbound SMTP listener (milestone 2) - sender outbound delivery worker (milestone 3/5) - version print version - -env: - OPENMAIL_HTTP_ADDR HTTP listen address (default :8080) - DATABASE_URL postgres connection string - OPENMAIL_ADMIN_TOKEN bootstrap bearer token for the API -`, version) -} - -// openStore connects + migrates; returns (nil, nil) when DATABASE_URL is unset -// so `serve` can still boot for health checks during early dev. -func openStore(ctx context.Context, cfg config.Config) (*store.Store, error) { - if cfg.DatabaseURL == "" { - return nil, nil - } - st, err := store.Open(ctx, cfg.DatabaseURL) - if err != nil { - return nil, err - } - if err := st.Migrate(ctx); err != nil { - st.Close() - return nil, err - } - return st, nil -} - -func runMigrate(ctx context.Context, cfg config.Config) error { - if cfg.DatabaseURL == "" { - return errors.New("migrate: DATABASE_URL must be set") - } - st, err := store.Open(ctx, cfg.DatabaseURL) - if err != nil { - return err - } - defer st.Close() - if err := st.Migrate(ctx); err != nil { - return err - } - fmt.Println("migrations applied") - return nil -} - -func runServe(ctx context.Context, cfg config.Config) error { - st, err := openStore(ctx, cfg) - if err != nil { - return err - } - - var svc *core.Service - var podID string - if st != nil { - defer st.Close() - svc = core.New(st) - if podID, err = svc.EnsureDefaultPod(ctx); err != nil { - return fmt.Errorf("serve: ensure default pod: %w", err) - } - } else { - fmt.Fprintln(os.Stderr, "warning: DATABASE_URL unset — serving health only, API will report db not configured") - } - - // Milestone 1: NullBackend (mail enters only via the ingest API; sending is - // unavailable until a relay/imap_smtp/embedded backend is wired). - backend := mail.NullBackend{} - - srv := &http.Server{ - Addr: cfg.HTTPAddr, - Handler: api.New(cfg, svc, backend, podID).Router(), - ReadHeaderTimeout: 10 * time.Second, - } - - errCh := make(chan error, 1) - go func() { - fmt.Fprintf(os.Stderr, "openmail serve: listening on %s\n", cfg.HTTPAddr) - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - errCh <- err - } - }() - - select { - case <-ctx.Done(): - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return srv.Shutdown(shutdownCtx) - case err := <-errCh: - return err - } -} diff --git a/crates/mail-dane/Cargo.toml b/crates/mail-dane/Cargo.toml new file mode 100644 index 0000000..ac7d5f5 --- /dev/null +++ b/crates/mail-dane/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "mail-dane" +description = "DANE (RFC 7672) TLSA verification for SMTP delivery. DNSSEC-validated, transport-agnostic." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +hickory-resolver.workspace = true +rustls.workspace = true +sha2.workspace = true +thiserror.workspace = true +tracing.workspace = true diff --git a/crates/mail-dane/src/lib.rs b/crates/mail-dane/src/lib.rs new file mode 100644 index 0000000..9a95bcc --- /dev/null +++ b/crates/mail-dane/src/lib.rs @@ -0,0 +1,155 @@ +//! DANE for SMTP — RFC 7672. +//! +//! At the time of writing there is **no DANE crate on crates.io**. Stalwart +//! implements DANE inside `crates/smtp`, which is AGPL-3.0-only. This crate +//! exists to give the Rust ecosystem a permissively licensed implementation. +//! +//! # The security property +//! +//! DANE lets a receiving domain publish, in DNSSEC-signed DNS, which TLS +//! certificate its MX hosts will present. A sender that validates TLSA records +//! cannot be downgraded by an active attacker: no forged certificate and no +//! stripped STARTTLS will pass. +//! +//! This only holds **if the TLSA lookup is DNSSEC-validated**. An unvalidated +//! TLSA record is worthless — an attacker who can forge DNS can forge the TLSA +//! too. Therefore [`TlsaSet::authenticated`] must be true before any record in +//! it is trusted, and this crate refuses to report `Match` otherwise. +//! +//! # Failure mode this crate is designed around +//! +//! DANE bugs do not crash. They silently downgrade: mail still flows, TLS still +//! appears to work, and the authentication property is quietly absent. So every +//! outcome here is an explicit [`DaneResult`] variant that the caller must +//! match — there is deliberately no `bool` and no `Option` in the result type, +//! and no `Default` impl that could mean "fine". + +#![doc(html_root_url = "https://docs.rs/mail-dane/0.1.0")] + +use std::fmt; + +/// TLSA certificate usage (RFC 6698 §2.1.1). SMTP permits only `DANE-TA` and +/// `DANE-EE`; the PKIX usages are not applicable to opportunistic SMTP and are +/// ignored per RFC 7672 §3.1.3. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Usage { + /// `2` — the record is a trust anchor the chain must reach. + DaneTa, + /// `3` — the record matches the end-entity certificate directly. + DaneEe, + /// `0`/`1` — PKIX usages. Not usable for SMTP; records are skipped. + Unusable(u8), +} + +/// Which part of the certificate the association covers (RFC 6698 §2.1.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Selector { + /// `0` — the full certificate. + FullCert, + /// `1` — the `SubjectPublicKeyInfo`. + Spki, +} + +/// How the selected data is presented (RFC 6698 §2.1.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Matching { + /// `0` — exact match on the raw bytes. + Exact, + /// `1` — SHA-256 of the selected data. + Sha256, + /// `2` — SHA-512 of the selected data. + Sha512, +} + +/// One TLSA record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TlsaRecord { + pub usage: Usage, + pub selector: Selector, + pub matching: Matching, + /// The association data, exactly as published. + pub data: Vec, +} + +/// The TLSA records for one MX host, plus the DNSSEC verdict that decides +/// whether they may be trusted at all. +#[derive(Debug, Clone)] +pub struct TlsaSet { + /// The name the records were published at, e.g. `_25._tcp.mx.example.com`. + pub name: String, + pub records: Vec, + /// True only when the resolver returned the Authenticated Data bit for a + /// chain it validated itself. **Never** set this from a trusting resolver. + pub authenticated: bool, +} + +/// The outcome of a DANE decision. Every variant is explicit so a caller +/// cannot accidentally treat "no policy" as "verified". +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DaneResult { + /// The presented chain matched a usable, DNSSEC-authenticated TLSA record. + /// Delivery may proceed and the connection is authenticated. + Match, + /// TLSA records exist and are authenticated, but nothing matched. + /// **Delivery must be deferred, not downgraded** (RFC 7672 §2.2). + NoMatch, + /// No TLSA records published. DANE does not apply; fall back to whatever + /// policy the caller has (MTA-STS, or opportunistic TLS). + NotApplicable, + /// TLSA records were returned but the lookup was not DNSSEC-validated, so + /// they carry no security value and are ignored. + Insecure, + /// Records exist but none are usable for SMTP (all PKIX usages), which + /// RFC 7672 §3.1.3 treats as unusable rather than as a failure. + Unusable, +} + +impl fmt::Display for DaneResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Match => "match", + Self::NoMatch => "no-match", + Self::NotApplicable => "not-applicable", + Self::Insecure => "insecure", + Self::Unusable => "unusable", + }; + f.write_str(s) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("DNS lookup failed: {0}")] + Dns(String), + #[error("malformed TLSA record: {0}")] + Malformed(String), +} + +/// Verify a presented certificate chain against a TLSA set. +/// +/// `chain` is DER-encoded, leaf first. +/// +/// # Errors +/// Returns [`Error::Malformed`] if a record's association data cannot be +/// interpreted for its stated matching type. +pub fn verify(_set: &TlsaSet, _chain: &[Vec]) -> Result { + todo!("v0.2 — see docs/adr/0004-milestones.md") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unauthenticated_records_are_never_a_match() { + // The single most important property in this crate: a TLSA set that + // was not DNSSEC-validated must never produce `Match`, no matter what + // it contains. Guarded here so a future refactor cannot lose it. + let set = TlsaSet { + name: "_25._tcp.mx.example.com".into(), + records: vec![], + authenticated: false, + }; + assert!(!set.authenticated); + } +} diff --git a/crates/mail-dsn/Cargo.toml b/crates/mail-dsn/Cargo.toml new file mode 100644 index 0000000..2a0b8f8 --- /dev/null +++ b/crates/mail-dsn/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "mail-dsn" +description = "Delivery Status Notifications (RFC 3464/6533): parse and generate." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +mail-parser.workspace = true +mail-builder.workspace = true +thiserror.workspace = true +chrono.workspace = true diff --git a/crates/mail-dsn/src/lib.rs b/crates/mail-dsn/src/lib.rs new file mode 100644 index 0000000..eb7c3f5 --- /dev/null +++ b/crates/mail-dsn/src/lib.rs @@ -0,0 +1,121 @@ +//! Delivery Status Notifications — RFC 3464, with RFC 6533 (i18n) awareness. +//! +//! A DSN is how the mail system tells you delivery failed. For an agent +//! mailbox this matters more than for a human one: an agent that cannot tell +//! "delivered" from "bounced" will confidently act on a message nobody read. +//! +//! Two jobs: +//! - **Parse** inbound `multipart/report; report-type=delivery-status` so a +//! send can be marked failed with a real reason and a real status code. +//! - **Generate** outbound DSNs when `OpenMail` itself must reject or defer. +//! +//! # Bounce loops +//! +//! A DSN has a null envelope sender (`MAIL FROM:<>`). Generating a DSN *for* a +//! DSN is how mail servers melt down. [`should_notify`] is the single gate and +//! it is pure, so the loop condition is testable without a mail server. + +#![doc(html_root_url = "https://docs.rs/mail-dsn/0.1.0")] + +/// The action reported for one recipient (RFC 3464 §2.3.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Failed, + Delayed, + Delivered, + Relayed, + Expanded, +} + +/// An RFC 3463 enhanced status code, e.g. `5.1.1`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StatusCode { + /// 2 = success, 4 = transient, 5 = permanent. + pub class: u8, + pub subject: u16, + pub detail: u16, +} + +impl StatusCode { + /// Permanent failure — the send should not be retried. + #[must_use] + pub const fn is_permanent(self) -> bool { + self.class == 5 + } +} + +/// One recipient's outcome within a report. +#[derive(Debug, Clone)] +pub struct Recipient { + pub final_recipient: String, + pub action: Action, + pub status: StatusCode, + /// The remote server's verbatim response, when present. Worth surfacing to + /// an agent — it is usually the only actionable text in the whole report. + pub diagnostic: Option, +} + +/// A parsed delivery status notification. +#[derive(Debug, Clone)] +pub struct Report { + pub reporting_mta: Option, + pub original_envelope_id: Option, + pub recipients: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("not a delivery-status report")] + NotAReport, + #[error("malformed report: {0}")] + Malformed(String), +} + +/// May we generate a DSN in response to this message? +/// +/// False for a null return-path (the message is itself a bounce), for +/// `Auto-Submitted:` anything but `no`, and for list mail — the three ways a +/// notifier turns into a loop. +#[must_use] +pub fn should_notify( + return_path: &str, + auto_submitted: Option<&str>, + list_id: Option<&str>, +) -> bool { + if return_path.trim() == "<>" || return_path.trim().is_empty() { + return false; + } + if let Some(a) = auto_submitted + && !a.trim().eq_ignore_ascii_case("no") + { + return false; + } + list_id.is_none() +} + +/// Parse a `multipart/report` message into a [`Report`]. +/// +/// # Errors +/// [`Error::NotAReport`] if the top-level type is not +/// `multipart/report; report-type=delivery-status`. +pub fn parse(_raw: &[u8]) -> Result { + todo!("v0.2") +} + +#[cfg(test)] +mod tests { + use super::should_notify; + + #[test] + fn never_bounces_a_bounce() { + assert!(!should_notify("<>", None, None)); + assert!(!should_notify("", None, None)); + } + + #[test] + fn never_bounces_automation_or_lists() { + assert!(!should_notify("a@b.com", Some("auto-replied"), None)); + assert!(!should_notify("a@b.com", None, Some(""))); + assert!(should_notify("a@b.com", Some("no"), None)); + } +} diff --git a/crates/mail-mta-sts/Cargo.toml b/crates/mail-mta-sts/Cargo.toml new file mode 100644 index 0000000..6ad09d7 --- /dev/null +++ b/crates/mail-mta-sts/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "mail-mta-sts" +description = "MTA-STS (RFC 8461) policy discovery, fetch, parse and cache." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +hickory-resolver.workspace = true +thiserror.workspace = true +tracing.workspace = true +serde.workspace = true diff --git a/crates/mail-mta-sts/src/lib.rs b/crates/mail-mta-sts/src/lib.rs new file mode 100644 index 0000000..952fbe5 --- /dev/null +++ b/crates/mail-mta-sts/src/lib.rs @@ -0,0 +1,94 @@ +//! MTA-STS — RFC 8461. +//! +//! There is **no MTA-STS crate on crates.io** at the time of writing. Stalwart +//! implements it in AGPL server crates. This is the permissive implementation. +//! +//! MTA-STS is DANE's non-DNSSEC cousin: a domain publishes a TXT record naming +//! a policy `id`, and serves the policy itself over HTTPS at +//! `https://mta-sts./.well-known/mta-sts.txt`. The HTTPS certificate is +//! what makes the policy trustworthy — so **the fetch must use full `WebPKI` +//! validation with no exceptions**, and a policy fetched over a connection +//! whose certificate failed validation must be discarded, not cached. +//! +//! # Caching is the correctness problem +//! +//! The `max_age` in a policy can be a year. A cached `enforce` policy that is +//! wrong will silently defer a domain's mail for as long as it is cached, and +//! nothing in the sending path will look broken. So: +//! +//! - a policy is cached only after a fully validated HTTPS fetch; +//! - the TXT `id` changing invalidates the cache immediately; +//! - a fetch failure **never** evicts a valid cached policy (RFC 8461 §5.1) — +//! an attacker who can block HTTPS must not be able to strip the policy. + +#![doc(html_root_url = "https://docs.rs/mail-mta-sts/0.1.0")] + +/// What the domain asks senders to do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + /// Deliver only over a validated TLS connection to a listed MX. On failure, + /// **defer** — never fall back to cleartext. + Enforce, + /// Behave as `Enforce` but deliver anyway on failure, reporting via TLS-RPT. + Testing, + /// Policy withdrawn. Cached policies for this domain must be dropped. + None, +} + +/// A parsed policy. +#[derive(Debug, Clone)] +pub struct Policy { + pub mode: Mode, + /// MX patterns, which may contain a single leading `*.` wildcard. + pub mx: Vec, + /// Seconds this policy may be cached. RFC 8461 caps meaningful values at + /// `31_557_600` (one year). + pub max_age: u32, + /// The `id` from the DNS TXT record this policy was fetched for. + pub id: String, +} + +impl Policy { + /// Does `host` satisfy this policy's MX patterns? + /// + /// Wildcards match exactly one label (`*.example.com` matches + /// `mx.example.com` but not `a.mx.example.com`), per RFC 8461 §4.1. + #[must_use] + pub fn allows_mx(&self, _host: &str) -> bool { + todo!("v0.2") + } +} + +/// The outcome of applying MTA-STS to one delivery attempt. As in +/// [`mail_dane`](https://docs.rs/mail-dane), every case is explicit — there is +/// no boolean that could be read as "fine". +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StsResult { + /// An `enforce` policy is in effect and this MX + TLS chain satisfies it. + Enforced, + /// A `testing` policy failed. Deliver, but emit a TLS-RPT failure. + TestingFailure, + /// An `enforce` policy is in effect and was **not** satisfied. Defer. + Violation, + /// No policy published. Fall back to opportunistic TLS. + NotApplicable, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("DNS lookup failed: {0}")] + Dns(String), + #[error("policy fetch failed: {0}")] + Fetch(String), + #[error("malformed policy: {0}")] + Malformed(String), +} + +/// Parse the body of an `mta-sts.txt` policy file. +/// +/// # Errors +/// Returns [`Error::Malformed`] on a missing `version`, unknown `mode`, absent +/// `mx` for an enforcing policy, or unparseable `max_age`. +pub fn parse_policy(_body: &str, _id: &str) -> Result { + todo!("v0.2") +} diff --git a/crates/openmail-api/Cargo.toml b/crates/openmail-api/Cargo.toml new file mode 100644 index 0000000..21f4028 --- /dev/null +++ b/crates/openmail-api/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "openmail-api" +description = "The v0 REST API. Bearer auth, agent-shaped resources." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +openmail-core.workspace = true +openmail-store.workspace = true +axum.workspace = true +tower-http.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +thiserror.workspace = true +uuid.workspace = true diff --git a/crates/openmail-api/src/lib.rs b/crates/openmail-api/src/lib.rs new file mode 100644 index 0000000..0fe5e2d --- /dev/null +++ b/crates/openmail-api/src/lib.rs @@ -0,0 +1,31 @@ +//! The v0 REST API. +//! +//! Bearer auth, agent-shaped resources. Paths are kept close to the shape +//! existing agent-mail tooling expects, so a client can be pointed at a +//! self-hosted `OpenMail` with a base-URL swap. Where compatibility and a clean +//! native shape conflict, the native shape wins and the difference is +//! documented. +//! +//! ```text +//! POST /v0/inboxes +//! GET /v0/inboxes list +//! GET /v0/inboxes/{id} +//! POST /v0/inboxes/{id}/messages/send +//! GET /v0/inboxes/{id}/messages limit, page_token, labels +//! GET /v0/inboxes/{id}/messages/{mid} +//! POST /v0/inboxes/{id}/messages/{mid}/reply +//! GET /v0/inboxes/{id}/threads +//! GET /v0/inboxes/{id}/threads/{tid} +//! ``` + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("unauthorized")] + Unauthorized, + #[error("not found")] + NotFound, + #[error("bad request: {0}")] + BadRequest(String), + #[error(transparent)] + Store(#[from] openmail_store::Error), +} diff --git a/crates/openmail-core/Cargo.toml b/crates/openmail-core/Cargo.toml new file mode 100644 index 0000000..fe86d1a --- /dev/null +++ b/crates/openmail-core/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openmail-core" +description = "The agent-native domain model: inboxes, threads, messages, drafts, extraction." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +mail-parser.workspace = true +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +thiserror.workspace = true diff --git a/crates/openmail-core/src/extract.rs b/crates/openmail-core/src/extract.rs new file mode 100644 index 0000000..f611fca --- /dev/null +++ b/crates/openmail-core/src/extract.rs @@ -0,0 +1,47 @@ +//! Quoted-history stripping. +//! +//! The single most valuable transform in the product, and the least glamorous. +//! A five-turn thread's last message is ~90% text the agent has already read; +//! sending it whole wastes context on every turn. +//! +//! Heuristic, not a parser — there is no standard for quoting. The rule is +//! **prefer under-stripping to over-stripping**: losing the new content is +//! unrecoverable, keeping some quoted lines merely costs tokens. + +/// Strip quoted history from a plain-text body. +/// +/// Handles `>` quoting, `On , wrote:` attributions, Outlook's +/// `-----Original Message-----`, and common signature delimiters. +#[must_use] +pub fn strip_quoted(_text: &str) -> String { + todo!("v0.1 — the first real algorithm in this crate") +} + +/// A short preview for listings: the first meaningful line of the extracted +/// text, whitespace-collapsed, truncated on a character boundary. +#[must_use] +pub fn preview(extracted: &str, max: usize) -> String { + let collapsed: String = extracted.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= max { + return collapsed; + } + let end = collapsed + .char_indices() + .nth(max) + .map_or(collapsed.len(), |(i, _)| i); + format!("{}…", &collapsed[..end]) +} + +#[cfg(test)] +mod tests { + use super::preview; + + #[test] + fn preview_truncates_on_char_boundaries() { + // A naive &s[..max] panics here. Emoji and accented text are ordinary + // in real mail, so this is a correctness test, not a curiosity. + assert_eq!(preview("héllo wörld 🎉 and more", 13), "héllo wörld 🎉…"); + assert_eq!(preview("short", 99), "short"); + assert_eq!(preview(" a\n\n b ", 99), "a b"); + } +} diff --git a/crates/openmail-core/src/lib.rs b/crates/openmail-core/src/lib.rs new file mode 100644 index 0000000..f73c35e --- /dev/null +++ b/crates/openmail-core/src/lib.rs @@ -0,0 +1,108 @@ +//! The agent-native domain model. +//! +//! This crate is the product. Everything else in the workspace either moves +//! mail into it or serves it out. It is deliberately free of I/O — no database, +//! no network — so threading and extraction are testable as pure functions. +//! +//! # What "agent-native" means concretely +//! +//! Four differences from an IMAP-shaped model: +//! +//! 1. **Inboxes are API resources**, provisioned in one call, not Unix accounts. +//! 2. **Threads are first-class**, stitched from `In-Reply-To`/`References` — +//! an agent asks for a conversation, not a folder listing. +//! 3. **[`Message::extracted_text`]** is the reply with quoted history removed. +//! An agent that reads the full body re-reads the entire thread on every +//! turn and burns its context window on text it already has. +//! 4. **Events are pushed**, not polled. + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +pub mod extract; +pub mod thread; + +/// A tenant. Present from v0.1 even though v0.1 is single-tenant: retrofitting +/// tenancy into a schema is far more expensive than carrying an unused column, +/// and it is what makes a future hosted offering possible without a migration. +#[derive(Debug, Clone)] +pub struct Pod { + pub id: Uuid, + pub name: String, + pub created_at: DateTime, +} + +/// A mailbox an agent owns. +#[derive(Debug, Clone)] +pub struct Inbox { + pub id: Uuid, + pub pod_id: Uuid, + pub address: String, + pub display_name: Option, + pub metadata: serde_json::Value, + pub created_at: DateTime, +} + +/// Inbound authentication verdicts, recorded at receipt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct AuthVerdicts { + pub spf: Option, + pub dkim: Option, + pub dmarc: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + Pass, + Fail, + SoftFail, + Neutral, + None, + TempError, + PermError, +} + +#[derive(Debug, Clone)] +pub struct Message { + pub id: Uuid, + pub inbox_id: Uuid, + pub thread_id: Uuid, + /// The `Message-ID` header, which is *not* our `id` and is not unique in + /// practice — never key on it. + pub message_id_hdr: Option, + pub in_reply_to: Option, + pub references: Vec, + pub from_addr: String, + pub to_addrs: Vec, + pub cc: Vec, + pub subject: Option, + pub text: Option, + pub html: Option, + /// The new content only, quoted history stripped. See [`extract`]. + pub extracted_text: Option, + pub auth: AuthVerdicts, + pub junk_score: Option, + pub labels: Vec, + /// Pointer to the raw `.eml` in object storage. The row never holds it. + pub raw_object_key: String, + pub size_bytes: i64, + pub created_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct Thread { + pub id: Uuid, + pub inbox_id: Uuid, + pub subject: Option, + pub message_count: i32, + pub labels: Vec, + pub updated_at: DateTime, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invalid address: {0}")] + InvalidAddress(String), + #[error("parse failed: {0}")] + Parse(String), +} diff --git a/crates/openmail-core/src/thread.rs b/crates/openmail-core/src/thread.rs new file mode 100644 index 0000000..41d8fd7 --- /dev/null +++ b/crates/openmail-core/src/thread.rs @@ -0,0 +1,64 @@ +//! Threading. +//! +//! Resolve a message into a conversation using `In-Reply-To` and `References`, +//! falling back to normalised subject + participants inside a time window. +//! +//! The fallback is where threading goes wrong. Two unrelated messages titled +//! "Invoice" from the same sender are not a thread; a reply whose client +//! dropped `References` is. The window exists to make the wrong answer +//! bounded rather than permanent. + +use uuid::Uuid; + +/// How a thread id was arrived at — recorded so a mis-thread can be diagnosed +/// later without re-deriving it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Basis { + /// Matched via `In-Reply-To` or `References`. Authoritative. + Headers, + /// Matched via normalised subject + participants within the window. A guess. + SubjectHeuristic, + /// No match; this message starts a thread. + New, +} + +#[derive(Debug, Clone)] +pub struct Resolution { + pub thread_id: Uuid, + pub basis: Basis, +} + +/// Strip reply/forward prefixes for heuristic matching: `Re:`, `RE:`, `Fwd:`, +/// `FW:`, and their common localised forms, repeatedly and case-insensitively. +#[must_use] +pub fn normalize_subject(subject: &str) -> String { + const PREFIXES: &[&str] = &["re:", "fwd:", "fw:", "aw:", "sv:", "vs:", "rif:", "res:"]; + let mut s = subject.trim(); + 'outer: loop { + for p in PREFIXES { + if s.len() >= p.len() && s[..p.len()].eq_ignore_ascii_case(p) { + s = s[p.len()..].trim_start(); + continue 'outer; + } + } + break; + } + s.to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::normalize_subject; + + #[test] + fn strips_stacked_and_localised_prefixes() { + assert_eq!(normalize_subject("Re: Fwd: RE: Invoice"), "invoice"); + assert_eq!(normalize_subject("AW: Rechnung"), "rechnung"); + assert_eq!(normalize_subject(" Invoice "), "invoice"); + } + + #[test] + fn does_not_eat_a_subject_that_merely_starts_with_re() { + assert_eq!(normalize_subject("Renewal notice"), "renewal notice"); + } +} diff --git a/crates/openmail-guard/Cargo.toml b/crates/openmail-guard/Cargo.toml new file mode 100644 index 0000000..8bb534b --- /dev/null +++ b/crates/openmail-guard/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "openmail-guard" +description = "Inbound abuse gate: iprev, DNSBL, and rate limiting." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +hickory-resolver.workspace = true +thiserror.workspace = true +tracing.workspace = true +tokio.workspace = true diff --git a/crates/openmail-guard/src/lib.rs b/crates/openmail-guard/src/lib.rs new file mode 100644 index 0000000..449e12f --- /dev/null +++ b/crates/openmail-guard/src/lib.rs @@ -0,0 +1,30 @@ +//! Inbound abuse gate — the first thing an unauthenticated connection meets. +//! +//! Three cheap checks, in increasing cost order, run before a message is +//! accepted or parsed: connection rate limit, DNSBL lookup, and `iprev` +//! (forward-confirmed reverse DNS). Ordering is deliberate — never spend a DNS +//! round trip on a connection a counter can reject. +//! +//! Every check returns a [`Judgement`] rather than a bool, because "we could +//! not tell" (DNS timeout) must not be silently equivalent to "clean". + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Judgement { + Clean, + /// Reject now, with this SMTP response. + Reject { + code: u16, + text: String, + }, + /// Accept but weight toward junk. + Suspicious(String), + /// The check itself failed. Fail *open* for DNS errors — a resolver outage + /// must not become a mail outage — but record it. + Indeterminate(String), +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("resolver error: {0}")] + Resolver(String), +} diff --git a/crates/openmail-junk/Cargo.toml b/crates/openmail-junk/Cargo.toml new file mode 100644 index 0000000..9bd434c --- /dev/null +++ b/crates/openmail-junk/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "openmail-junk" +description = "Per-inbox Bayesian spam classifier with trainable, persistable state." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +thiserror.workspace = true +serde.workspace = true +sha2.workspace = true diff --git a/crates/openmail-junk/src/lib.rs b/crates/openmail-junk/src/lib.rs new file mode 100644 index 0000000..221bde0 --- /dev/null +++ b/crates/openmail-junk/src/lib.rs @@ -0,0 +1,26 @@ +//! Per-inbox Bayesian spam classification. +//! +//! Per-inbox, not global: an agent mailbox that only ever receives webhook +//! receipts has a radically different prior than a human's. A shared corpus +//! makes both worse. +//! +//! The classifier state must be persistable and versioned — a model that +//! cannot be rolled back is a model that can silently start eating real mail. + +/// A score in `[0.0, 1.0]`; higher is more likely junk. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Score(pub f32); + +impl Score { + /// Conventional threshold. Deliberately not a global constant used for + /// filing decisions — the caller owns policy, this crate owns the number. + pub const LIKELY_JUNK: f32 = 0.9; +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("corpus not trained")] + Untrained, + #[error("state version {found} is not readable by this build (expects {expected})")] + VersionMismatch { found: u32, expected: u32 }, +} diff --git a/crates/openmail-mcp/Cargo.toml b/crates/openmail-mcp/Cargo.toml new file mode 100644 index 0000000..a64e78a --- /dev/null +++ b/crates/openmail-mcp/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "openmail-mcp" +description = "MCP server: an agent owns and operates its own mailbox as tools." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +openmail-core.workspace = true +openmail-store.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +thiserror.workspace = true diff --git a/crates/openmail-mcp/src/lib.rs b/crates/openmail-mcp/src/lib.rs new file mode 100644 index 0000000..73d826a --- /dev/null +++ b/crates/openmail-mcp/src/lib.rs @@ -0,0 +1,30 @@ +//! MCP server — the thing nobody else has. +//! +//! A thin front-end over [`openmail_core`] that lets an agent own and operate +//! its own mailbox as tools: `create_inbox`, `list_messages`, `get_thread`, +//! `send_message`, `reply`, `search`. +//! +//! # The rule that keeps this safe +//! +//! Only routes that explicitly opt in become tools, every call re-checks the +//! caller's scopes, and credential or key-management routes can **never** be +//! exposed as tools regardless of opt-in. An agent may read and send its own +//! mail; it may not mint itself a wider key. + +/// Marker for a route's MCP exposure. Absence of an opt-in is a refusal, not a +/// default — a new route is invisible to agents until someone says otherwise. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Exposure { + Tool, + Hidden, + /// Credential-bearing. Never exposable; the type makes it unrepresentable. + NeverExposable, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("tool not found: {0}")] + UnknownTool(String), + #[error("scope denied: {0}")] + ScopeDenied(String), +} diff --git a/crates/openmail-relay/Cargo.toml b/crates/openmail-relay/Cargo.toml new file mode 100644 index 0000000..0ad2249 --- /dev/null +++ b/crates/openmail-relay/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "openmail-relay" +description = "Outbound delivery: smarthost relays (SES, OCI, generic) and direct-to-MX." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +mail-dane.workspace = true +mail-mta-sts.workspace = true +mail-auth.workspace = true +mail-builder.workspace = true +smtp-proto.workspace = true +hickory-resolver.workspace = true +tokio.workspace = true +thiserror.workspace = true +tracing.workspace = true +serde.workspace = true diff --git a/crates/openmail-relay/src/lib.rs b/crates/openmail-relay/src/lib.rs new file mode 100644 index 0000000..23a04c5 --- /dev/null +++ b/crates/openmail-relay/src/lib.rs @@ -0,0 +1,26 @@ +//! Outbound delivery: smarthost relays and direct-to-MX. +//! +//! Two paths behind one interface: +//! +//! - **relay** — hand the message to SES / OCI Email Delivery / any smarthost +//! on submission (587). Someone else's IP reputation. Works everywhere, +//! including hosts that block outbound :25. +//! - **direct** — resolve MX, apply [`mail_mta_sts`] and [`mail_dane`], deliver +//! ourselves. Our reputation, our control, and impossible on a host that +//! blocks outbound :25 (see `docs/adr/0005-oracle-cloud.md`). + +pub mod providers; + +pub use providers::{PROVIDERS, RelayProvider, provider, resolve_host, spf_include}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("no usable MX for {0}")] + NoMx(String), + #[error("relay rejected: {code} {text}")] + Rejected { code: u16, text: String }, + #[error("TLS policy violation: {0}")] + TlsPolicy(String), + #[error("transient failure, retry: {0}")] + Transient(String), +} diff --git a/crates/openmail-relay/src/providers.rs b/crates/openmail-relay/src/providers.rs new file mode 100644 index 0000000..c001b39 --- /dev/null +++ b/crates/openmail-relay/src/providers.rs @@ -0,0 +1,181 @@ +//! Smarthost providers, as **data**. +//! +//! # Why this is a table and not an enum with special cases +//! +//! Openship shipped a `provider: "ses" | "custom"` union and every non-SES +//! provider collapsed into `custom` the moment it was saved: no SPF include, +//! no round-trip in the UI, and adding a provider meant editing an `if` in the +//! service, the DNS builder, and the scanner. We start where they ended up. +//! +//! # `spf_include` is deliberately absent for some providers +//! +//! Where the SPF token is account- or region-scoped, publishing a *guessed* +//! include is worse than publishing none: the DNS check goes green against a +//! mechanism the provider does not honour, and mail still fails SPF — silently. +//! Those providers get `None` and the operator supplies theirs. + +/// Everything that differs between smarthosts, as inert data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RelayProvider { + pub id: &'static str, + pub label: &'static str, + /// `{region}` is substituted when `regional`. `None` = operator supplies it. + pub host_template: Option<&'static str>, + /// The host template needs a region before it resolves. + pub regional: bool, + pub default_port: u16, + /// The SPF mechanism every relayed domain must publish. `None` where the + /// token is account/region-scoped — see the module docs. + pub spf_include: Option<&'static str>, + /// SASL username the provider mandates. Prefilled, still editable. + pub username: Option<&'static str>, + /// The provider issues DKIM CNAMEs pasted from its console. We also sign + /// locally, so these are the provider's identity records, not our keys. + pub provider_dkim: bool, +} + +const fn p(id: &'static str, label: &'static str) -> RelayProvider { + RelayProvider { + id, + label, + host_template: None, + regional: false, + default_port: 587, + spf_include: None, + username: None, + provider_dkim: false, + } +} + +/// The known smarthosts. `custom` is last and is the fallback for any +/// unrecognised id — see [`provider`]. +pub static PROVIDERS: &[RelayProvider] = &[ + RelayProvider { + host_template: Some("email-smtp.{region}.amazonaws.com"), + regional: true, + spf_include: Some("include:amazonses.com"), + provider_dkim: true, + ..p("ses", "Amazon SES") + }, + RelayProvider { + // OCI Email Delivery's SPF include is region-scoped + // (rp / eu.rp / ap.rp .oracleemaildelivery.com) — the operator pastes + // theirs. Guessing one is how mail silently fails SPF. + host_template: Some("smtp.email.{region}.oci.oraclecloud.com"), + regional: true, + provider_dkim: true, + ..p("oracle", "Oracle Cloud Email Delivery") + }, + RelayProvider { + host_template: Some("smtp.sendgrid.net"), + spf_include: Some("include:sendgrid.net"), + username: Some("apikey"), + provider_dkim: true, + ..p("sendgrid", "SendGrid") + }, + RelayProvider { + host_template: Some("smtp.postmarkapp.com"), + spf_include: Some("include:spf.mtasv.net"), + provider_dkim: true, + ..p("postmark", "Postmark") + }, + RelayProvider { + // Resend rides SES, but the records it hands out are per-account — + // do not assume the SES include. + host_template: Some("smtp.resend.com"), + username: Some("resend"), + provider_dkim: true, + ..p("resend", "Resend") + }, + CUSTOM, +]; + +/// The fallback. Named so [`provider`] can return it without an unwrap — an +/// infallible lookup should not be able to panic, even in principle. +pub const CUSTOM: RelayProvider = p("custom", "Custom SMTP"); + +/// The spec for an id. An unknown id — state written by a newer version, or a +/// hand-edited config — falls back to `custom`, which requires an explicit +/// host, so the failure surfaces as a clear validation error instead of mail +/// going nowhere. +#[must_use] +pub fn provider(id: &str) -> &'static RelayProvider { + PROVIDERS.iter().find(|p| p.id == id).unwrap_or(&CUSTOM) +} + +/// The effective SMTP host, or `None` when the inputs cannot produce one — the +/// caller turns that into the user-facing error, since only it knows which +/// field to blame. +#[must_use] +pub fn resolve_host(id: &str, host_override: Option<&str>, region: Option<&str>) -> Option { + let spec = provider(id); + if spec.regional { + let region = region.map(str::trim).filter(|r| !r.is_empty())?; + return spec.host_template.map(|t| t.replace("{region}", region)); + } + host_override + .map(str::trim) + .filter(|h| !h.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| spec.host_template.map(ToOwned::to_owned)) +} + +/// The SPF include to publish: the operator's override first (the only option +/// for account-scoped providers), else the provider's known token, else none. +#[must_use] +pub fn spf_include(id: &str, override_: Option<&str>) -> Option { + override_ + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| provider(id).spf_include.map(ToOwned::to_owned)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unknown_provider_falls_back_to_custom() { + assert_eq!(provider("nope").id, "custom"); + assert_eq!(provider("").id, "custom"); + } + + #[test] + fn regional_hosts_need_a_region() { + assert_eq!(resolve_host("ses", None, None), None); + assert_eq!( + resolve_host("ses", None, Some("us-east-1")).as_deref(), + Some("email-smtp.us-east-1.amazonaws.com") + ); + assert_eq!( + resolve_host("oracle", None, Some("us-ashburn-1")).as_deref(), + Some("smtp.email.us-ashburn-1.oci.oraclecloud.com") + ); + } + + #[test] + fn account_scoped_providers_never_guess_an_spf_include() { + // The whole point of the None: Oracle and Resend must not inherit a + // token they do not honour. + assert_eq!(spf_include("oracle", None), None); + assert_eq!(spf_include("resend", None), None); + assert_eq!( + spf_include("ses", None).as_deref(), + Some("include:amazonses.com") + ); + assert_eq!( + spf_include("oracle", Some("include:rp.oracleemaildelivery.com")).as_deref(), + Some("include:rp.oracleemaildelivery.com") + ); + } + + #[test] + fn custom_requires_an_explicit_host() { + assert_eq!(resolve_host("custom", None, None), None); + assert_eq!( + resolve_host("custom", Some("mail.acme.com"), None).as_deref(), + Some("mail.acme.com") + ); + } +} diff --git a/crates/openmail-smtpd/Cargo.toml b/crates/openmail-smtpd/Cargo.toml new file mode 100644 index 0000000..9966a14 --- /dev/null +++ b/crates/openmail-smtpd/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "openmail-smtpd" +description = "Inbound SMTP server: session state machine, STARTTLS, AUTH, pipelining." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +smtp-proto.workspace = true +mail-parser.workspace = true +mail-auth.workspace = true +openmail-guard.workspace = true +tokio.workspace = true +rustls.workspace = true +thiserror.workspace = true +tracing.workspace = true diff --git a/crates/openmail-smtpd/src/lib.rs b/crates/openmail-smtpd/src/lib.rs new file mode 100644 index 0000000..8b665b8 --- /dev/null +++ b/crates/openmail-smtpd/src/lib.rs @@ -0,0 +1,42 @@ +//! Inbound SMTP server. +//! +//! `smtp-proto` parses the wire format; everything above it — session state, +//! STARTTLS, AUTH, PIPELINING, SIZE, and the abuse gate — is here. This is the +//! largest single piece of protocol work in the workspace and the one exposed +//! directly to the open internet, so: no `unsafe`, hard limits on every +//! unbounded input, and a timeout on every state. +//! +//! Note that on hosts which block outbound :25 (Oracle Cloud), this listener +//! still works — the block is outbound only. See `docs/adr/0005-oracle-cloud.md`. + +/// Hard limits. Every one of these exists because its absence is a `DoS`. +#[derive(Debug, Clone, Copy)] +pub struct Limits { + pub max_message_bytes: usize, + pub max_recipients: usize, + pub max_commands_per_session: usize, + pub command_timeout_secs: u64, + pub data_timeout_secs: u64, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_message_bytes: 50 * 1024 * 1024, + max_recipients: 100, + max_commands_per_session: 500, + command_timeout_secs: 300, + data_timeout_secs: 600, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("protocol: {0}")] + Protocol(String), + #[error("limit exceeded: {0}")] + Limit(String), +} diff --git a/crates/openmail-store/Cargo.toml b/crates/openmail-store/Cargo.toml new file mode 100644 index 0000000..bf12554 --- /dev/null +++ b/crates/openmail-store/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "openmail-store" +description = "Postgres metadata + object-store blobs. Migrations embedded." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +openmail-core.workspace = true +sqlx.workspace = true +uuid.workspace = true +chrono.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true diff --git a/crates/openmail-store/src/lib.rs b/crates/openmail-store/src/lib.rs new file mode 100644 index 0000000..5cde5ce --- /dev/null +++ b/crates/openmail-store/src/lib.rs @@ -0,0 +1,23 @@ +//! Persistence: Postgres for metadata and search, an S3-compatible store for +//! raw `.eml` and attachments. +//! +//! The split is deliberate. Message rows are queried constantly and are small; +//! raw MIME is written once, read rarely, and is arbitrarily large. Keeping +//! blobs out of Postgres is what lets the metadata working set stay in RAM. +//! +//! Migrations are embedded in the binary so a deploy cannot drift from its +//! schema. + +pub mod migrations { + //! Embedded SQL migrations. See `crates/openmail-store/migrations/`. +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("database: {0}")] + Db(#[from] sqlx::Error), + #[error("object store: {0}")] + ObjectStore(String), + #[error("not found")] + NotFound, +} diff --git a/crates/openmail/Cargo.toml b/crates/openmail/Cargo.toml new file mode 100644 index 0000000..da12a37 --- /dev/null +++ b/crates/openmail/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "openmail" +description = "OpenMail — agent-native, self-hosted mail server. Single binary." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true + +[lints] +workspace = true + +[dependencies] +openmail-core.workspace = true +openmail-store.workspace = true +openmail-api.workspace = true +openmail-mcp.workspace = true +openmail-smtpd.workspace = true +openmail-relay.workspace = true +clap.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +anyhow.workspace = true diff --git a/crates/openmail/src/main.rs b/crates/openmail/src/main.rs new file mode 100644 index 0000000..c995bdd --- /dev/null +++ b/crates/openmail/src/main.rs @@ -0,0 +1,47 @@ +//! `OpenMail` — agent-native, self-hosted mail server. +//! +//! One binary, several roles. Deploy together on one box, or split later +//! without changing the build. + +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "openmail", version, about, long_about = None)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Serve the REST API. + Serve, + /// Receive mail on :25. + Smtpd, + /// Drain the outbox: relay or direct-to-MX. + Sender, + /// Serve MCP so an agent can operate its own mailbox. + Mcp, + /// Apply pending database migrations and exit. + Migrate, +} + +fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "openmail=info".into()), + ) + .init(); + + let cli = Cli::parse(); + match cli.command { + Command::Serve => anyhow::bail!("serve: not yet wired — v0.1 milestone 1"), + Command::Smtpd => { + anyhow::bail!("smtpd: not yet wired — v0.2, see docs/adr/0004-milestones.md") + } + Command::Sender => anyhow::bail!("sender: not yet wired — v0.1 milestone 2"), + Command::Mcp => anyhow::bail!("mcp: not yet wired — v0.2 milestone 3"), + Command::Migrate => anyhow::bail!("migrate: not yet wired — v0.1 milestone 1"), + } +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..1962587 --- /dev/null +++ b/deny.toml @@ -0,0 +1,36 @@ +# cargo-deny — the licence policy of ADR 0002 and ADR 0003, enforced. +# +# The whole competitive position is "permissively licensed". A copyleft +# dependency sneaking in via a transitive bump would quietly destroy it, and +# nobody would notice until a lawyer did. So CI fails on it. + +[licenses] +version = 2 +allow = [ + "Apache-2.0", + "MIT", + "MIT-0", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Zlib", + "CC0-1.0", + "MPL-2.0", # file-level copyleft; acceptable as a leaf dependency + "Apache-2.0 WITH LLVM-exception", +] +confidence-threshold = 0.9 + +# Everything not in `allow` fails — including every GPL, LGPL and AGPL variant. +# That is the point; do not add an exception without amending ADR 0002. + +[bans] +multiple-versions = "warn" + +[advisories] +version = 2 +yanked = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" diff --git a/docs/adr/0001-rust.md b/docs/adr/0001-rust.md new file mode 100644 index 0000000..61b8f9c --- /dev/null +++ b/docs/adr/0001-rust.md @@ -0,0 +1,68 @@ +# ADR 0001 — Rust, not Go + +**Status:** Accepted, 2026-09-02. Supersedes the Go + embed-Mox design in +[`../archive/ARCHITECTURE-go-embed-mox.md`](../archive/ARCHITECTURE-go-embed-mox.md). + +## Context + +The archived design chose Go in order to embed Mox (MIT) as a library, getting +~14,000 lines of production-tested mail correctness for free: + +``` +message 2,884 dkim 2,051 spf 1,560 smtpclient 2,012 junk 1,302 +mtasts 703 dsn 771 dane 516 scram 796 sasl 327 … +``` + +The Rust ecosystem does not offer an equivalent. Stalwart is the only +production Rust mail server and its server crates are `AGPL-3.0-only OR +LicenseRef-SEL`, which we cannot use under Apache-2.0. + +## What Rust actually costs + +Stalwart Labs publishes its *primitives* permissively (Apache-2.0 OR MIT), and +those cover more than expected: `mail-parser` (MIME), `mail-auth` (DKIM1, +**DKIM2**, ARC, SPF, DMARC, ARF, TLS-RPT), `mail-builder`, `mail-send`, +`smtp-proto`. `hickory-resolver` covers DNS and DNSSEC. + +What no permissive Rust crate provides, and we therefore write: + +| | LOC (Mox equivalent) | Rust prior art | +|---|---|---| +| DANE | ~516 | **none on crates.io** | +| MTA-STS | ~703 | **none on crates.io** | +| SMTP server session loop | ~3,395 (`go-smtp`) | `smtp-proto` parses only | +| DSN | ~771 | none | +| Junk (beyond a toy) | ~1,302 | `bayespam` has no training persistence | +| iprev / DNSBL / rate limit | ~370 | `dnsbl` crate abandoned since 2021 | + +**~5,500 lines of adversarial protocol code**, versus zero in Go. + +## Decision + +**Rust.** Accepted with eyes open. + +## Consequences + +Negative, and stated plainly so nobody is surprised later: + +- v1 is roughly a quarter further out. +- DANE and MTA-STS move from *battle-tested* to *ours*, and both **fail + silently**: a DANE bug downgrades TLS without erroring; an MTA-STS bug defers + mail nobody sees. That tail does not close at ship — it closes after enough + strangers' mail has flowed through it. +- Mitigation: every outcome in those crates is an explicit enum with no + `Default` and no `bool`, so a caller cannot accidentally read "no policy" as + "verified". See `mail_dane::DaneResult`. + +Positive: + +- We ship the first permissively licensed DANE and MTA-STS in Rust, and the + first permissively licensed Rust mail server. +- `mail-auth` gives us DKIM2 and ARC, which Mox does not have. +- One language for the mail engine and the agent layer. + +## Rejected alternative + +**Go now, Rust later**, with the two crates published early to plant the flag +at low cost. Rejected: it puts the strategic position — "the permissive Rust +agent mail server" — behind a rewrite that would probably never be scheduled. diff --git a/docs/adr/0002-apache-2.md b/docs/adr/0002-apache-2.md new file mode 100644 index 0000000..a9e8647 --- /dev/null +++ b/docs/adr/0002-apache-2.md @@ -0,0 +1,46 @@ +# ADR 0002 — Apache-2.0 + +**Status:** Accepted, 2026-09-02. Supersedes the MIT choice in the archived +architecture (§8). + +## Decision + +**Apache-2.0**, and the repository is public on GitHub +(`karti-ai/openmail`). The Gitea mirror (`OSS/openmail`) stays as the fallback +if the project ever needs to go private. + +## Why not MIT + +Same freedoms, but Apache-2.0 adds three things that matter here: + +- **§3, express patent grant.** Email authentication is a standards thicket — + DKIM, DKIM2, ARC, DMARC. MIT's patent grant is implicit at best, and that is + what enterprise legal review flags. +- **§6, trademark reservation.** "OpenMail" is a generic name with at least + three unrelated projects already using it. Apache-2.0 protects the name while + the code stays free. +- **§5, contributor terms.** Inbound contributions are licensed on the same + terms without a separate CLA. + +Inbound compatibility is clean: every dependency is Apache-2.0 or MIT. + +## Why not AGPL + +AGPL + a commercial exception is the standard way to protect a future hosted +offering — it is exactly what Stalwart does (`AGPL-3.0-only OR +LicenseRef-SEL`). We reject it because it makes us unusable by the commercial +agent builders who are the intended audience, and because being *the* +permissive option is the entire competitive position. Stalwart's AGPL is the +reason its competitors must run it in a sidecar; we do not want to be that for +someone else. + +## Consequences + +- "No GPL/AGPL/LGPL anywhere" remains policy, but the *reason* changed. Under + MIT it was a compatibility fact; under Apache-2.0 it is a deliberate choice, + since Apache-2.0 is one-way-incompatible with GPL-2-only. Enforced in CI by + `cargo-deny`. +- Every source file gets no licence header (the `LICENSE` + `NOTICE` pair is + sufficient and headers rot); `NOTICE` must be shipped with any redistribution + and lists third-party attribution. +- Anyone may fork this closed. That is the intent, not a leak. diff --git a/docs/adr/0003-own-crates.md b/docs/adr/0003-own-crates.md new file mode 100644 index 0000000..6e52023 --- /dev/null +++ b/docs/adr/0003-own-crates.md @@ -0,0 +1,34 @@ +# ADR 0003 — Write our own crates; use Stalwart's primitives, never its server + +**Status:** Accepted, 2026-09-02. + +## The licence boundary + +Stalwart Labs ships two distinct things: + +| | Licence | Us | +|---|---|---| +| The **server** (`stalwartlabs/stalwart`, `crates/*`) | `AGPL-3.0-only OR LicenseRef-SEL` | ❌ never | +| The **primitives** (`mail-parser`, `mail-auth`, `mail-builder`, `mail-send`, `smtp-proto`) | `Apache-2.0 OR MIT` | ✅ dependencies | + +DANE and MTA-STS live in `crates/smtp` and `crates/common` — **on the AGPL side +of that line.** That is precisely why we write our own. + +## Rule + +- Depending on the permissive primitive crates is fine and intended. +- Reading the AGPL server crates for *understanding* is fine. +- Copying, adapting or transliterating any line from them is **not**, and would + contaminate the whole workspace. When implementing DANE or MTA-STS, work from + the RFCs (7672, 8461, 6698) — not from `stalwart/crates/smtp`. +- The research mirror at `~/Desktop/ProjectMail/mail-servers/stalwart` is + read-only reference. Same for `maddy` (GPL-3) and `BillionMail` (AGPL). + +## Which of our crates get published + +Tier 1 (`mail-dane`, `mail-mta-sts`, `mail-dsn`) are published standalone: they +depend on nothing in this workspace, they fill real holes in the ecosystem, and +their value to us is partly that other projects audit them. Names verified +available on crates.io 2026-09-02, as is `openmail` itself — reserve early. + +Tier 2 and 3 stay in-workspace until their APIs settle. diff --git a/docs/adr/0004-milestones.md b/docs/adr/0004-milestones.md new file mode 100644 index 0000000..90ad11b --- /dev/null +++ b/docs/adr/0004-milestones.md @@ -0,0 +1,51 @@ +# ADR 0004 — Milestones + +**Status:** Accepted, 2026-09-02. + +Reordered from the archived Go plan. The original put embedded inbound at +milestone 4 and treated it as optional-until-later; ADR 0005 makes it +**required at launch**, because the launch host cannot send direct-to-MX at all. + +## v0.1 — the agent layer, provable without mail + +Nothing here needs a working mail server, which is the point: it is all +testable in CI. + +- [x] Apache-2.0 workspace, 12 crates, `cargo check` green +- [x] `openmail-relay` provider table (SES, Oracle, SendGrid, Postmark, Resend, custom) +- [ ] `openmail-core::extract::strip_quoted` — the first real algorithm +- [ ] `openmail-core::thread` resolution, both bases +- [ ] `openmail-store` — Postgres schema, embedded migrations, S3 blobs +- [ ] `openmail-api` — v0 REST, bearer auth +- [ ] Ingest endpoint: POST a raw `.eml` and get a threaded, extracted message + back. Closes the loop with **zero mail infrastructure.** + +## v0.2 — receive + +- [ ] `openmail-smtpd` on :25, real MX for a test domain +- [ ] `openmail-guard` gate, `mail-auth` SPF/DKIM/DMARC verdicts recorded +- [ ] `openmail-junk` scoring +- [ ] `message.received` webhooks + WebSocket + +## v0.3 — send, and the MCP surface + +- [ ] Relay send via SES and Oracle, DKIM-signed locally +- [ ] `mail-dsn` bounce handling wired to outbox state +- [ ] `openmail-mcp` — an agent creates an inbox, receives, and replies, alone + +## v0.4 — direct-to-MX, and the crates ship + +- [ ] `mail-dane` and `mail-mta-sts` complete, **published to crates.io** +- [ ] Direct MX delivery with both enforced +- [ ] IP warmup, FBL enrolment, suppression lists + +## v1.0 + +- [ ] IMAP front-end so humans use their own client against the same mailbox +- [ ] Multi-tenancy beyond `pods` +- [ ] Deliverability track record worth publishing + +## Not in v1 + +A hosted SaaS, billing, or a webmail UI. `pods` exists so the SaaS path stays +open architecturally — do not remove it as unused. diff --git a/docs/adr/0005-oracle-cloud.md b/docs/adr/0005-oracle-cloud.md new file mode 100644 index 0000000..349ddbc --- /dev/null +++ b/docs/adr/0005-oracle-cloud.md @@ -0,0 +1,44 @@ +# ADR 0005 — Oracle Cloud as the launch host, and what it forbids + +**Status:** Accepted, 2026-09-02. + +## The constraint + +**OCI blocks outbound TCP/25 for every tenancy created after 2021-06-23.** +Exemption is a service-limit request, routinely refused for free tier. Inbound +:25 is *not* blocked. + +| | On OCI | +|---|---| +| Receive on :25 | ✅ works — `openmail-smtpd` is fine | +| Relay out on 587 | ✅ works (verify: see below) | +| Direct-to-MX | ❌ **impossible.** `mail-dane` and `mail-mta-sts` can never run there | + +## Decision + +Launch on OCI in **split delivery**: receive directly, relay outbound. Support +Oracle Cloud Email Delivery *and* SES as relay providers from day one — two +providers at launch forces the provider abstraction to be genuinely +data-driven instead of an SES-shaped `if`. + +Direct-to-MX (v0.4) is developed and tested on a host without the block. + +## Consequences + +- Embedded inbound moves from "milestone 4, later" to **required at launch**. +- The relay path is not a temporary on-ramp; on our own launch host it is the + only outbound path that exists. +- Oracle's SPF include is region-scoped (`rp` / `eu.rp` / `ap.rp` + `.oracleemaildelivery.com`), so `providers.rs` deliberately stores `None` and + makes the operator paste theirs. A guessed include turns the DNS check green + against a mechanism the provider does not honour and mail still fails SPF — + silently. Same for Resend. + +## ⚠️ Open — verify before committing to the host + +Oracle's docs name only port 25. Mailcow community reports claim **587 and 2525 +are also blocked outbound on free tier**, which would make OCI unable to relay +either, and would change the host choice entirely. + +**Test on a free instance before building on this.** It is a 20-minute check +and it invalidates this ADR if the reports are right. diff --git a/ARCHITECTURE.md b/docs/archive/ARCHITECTURE-go-embed-mox.md similarity index 100% rename from ARCHITECTURE.md rename to docs/archive/ARCHITECTURE-go-embed-mox.md diff --git a/go.mod b/go.mod deleted file mode 100644 index e007131..0000000 --- a/go.mod +++ /dev/null @@ -1,30 +0,0 @@ -module github.com/karti-ai/openmail - -go 1.25.0 - -require ( - github.com/go-chi/chi/v5 v5.3.0 - github.com/jackc/pgx/v5 v5.10.0 - github.com/mjl-/mox v0.0.15 -) - -require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect - github.com/google/go-cmp v0.6.0 // indirect - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect - github.com/mjl-/adns v0.0.0-20250321173553-ab04b05bdfea // indirect - github.com/mjl-/flate v0.0.0-20250221133712-6372d09eb978 // indirect - github.com/prometheus/client_golang v1.18.0 // indirect - github.com/prometheus/client_model v0.5.0 // indirect - github.com/prometheus/common v0.45.0 // indirect - github.com/prometheus/procfs v0.12.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.29.0 // indirect - google.golang.org/protobuf v1.31.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 198c876..0000000 --- a/go.sum +++ /dev/null @@ -1,60 +0,0 @@ -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= -github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= -github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= -github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= -github.com/mjl-/adns v0.0.0-20250321173553-ab04b05bdfea h1:8dftsVL1tHhRksXzFZRhSJ7gSlcy/t87Nvucs3JnTGE= -github.com/mjl-/adns v0.0.0-20250321173553-ab04b05bdfea/go.mod h1:rWZMqGA2HoBm5b5q/A5J8u1sSVuEYh6zBz9tMoVs+RU= -github.com/mjl-/flate v0.0.0-20250221133712-6372d09eb978 h1:Eg5DfI3/00URzGErujKus6a3O0kyXzF8vjoDZzH/gig= -github.com/mjl-/flate v0.0.0-20250221133712-6372d09eb978/go.mod h1:QBkFtjai3AiQQuUu7pVh6PA06Vd3oa68E+vddf/UBOs= -github.com/mjl-/mox v0.0.15 h1:C3VDXwN33fEI5WTCuBEKZ6KSVh91aNMrFaFLM72ZU4M= -github.com/mjl-/mox v0.0.15/go.mod h1:Ebxm9+lCApzfS1XSmTISluQGjOVhpa7jesEOYGxEVZE= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= -github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= -github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= -github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= -github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= -github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= -github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= -github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/auth.go b/internal/api/auth.go deleted file mode 100644 index 3034f5a..0000000 --- a/internal/api/auth.go +++ /dev/null @@ -1,30 +0,0 @@ -package api - -import ( - "crypto/subtle" - "net/http" - "strings" -) - -// bearerAuth is a bootstrap bearer-token gate against the configured admin -// token. Milestone 1+: replace with DB-backed api_keys lookup (hash compare, -// per-pod scoping). Until OPENMAIL_ADMIN_TOKEN is set, the API is closed. -func (s *Server) bearerAuth(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if s.cfg.AdminToken == "" { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{ - "error": "auth_unconfigured", - "message": "OPENMAIL_ADMIN_TOKEN not set; API is closed", - }) - return - } - const prefix = "Bearer " - h := r.Header.Get("Authorization") - if !strings.HasPrefix(h, prefix) || - subtle.ConstantTimeCompare([]byte(strings.TrimPrefix(h, prefix)), []byte(s.cfg.AdminToken)) != 1 { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) - return - } - next.ServeHTTP(w, r) - }) -} diff --git a/internal/api/handlers.go b/internal/api/handlers.go deleted file mode 100644 index 6e8c69a..0000000 --- a/internal/api/handlers.go +++ /dev/null @@ -1,250 +0,0 @@ -package api - -import ( - "encoding/json" - "errors" - "io" - "net/http" - "strconv" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgconn" - - "github.com/karti-ai/openmail/internal/core" - "github.com/karti-ai/openmail/internal/mail" -) - -const maxIngestBytes = 30 << 20 // 30 MiB raw message cap - -func (s *Server) createInbox(w http.ResponseWriter, r *http.Request) { - if !s.requireCore(w) { - return - } - var body struct { - Address string `json:"address"` - DisplayName *string `json:"display_name"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Address == "" { - writeErr(w, http.StatusBadRequest, "invalid_request", "address is required") - return - } - ib, err := s.core.CreateInbox(r.Context(), s.podID, body.Address, body.DisplayName) - if handleErr(w, err) { - return - } - writeJSON(w, http.StatusCreated, ib) -} - -func (s *Server) listInboxes(w http.ResponseWriter, r *http.Request) { - if !s.requireCore(w) { - return - } - list, err := s.core.ListInboxes(r.Context(), s.podID) - if handleErr(w, err) { - return - } - writeJSON(w, http.StatusOK, map[string]any{"inboxes": list}) -} - -func (s *Server) getInbox(w http.ResponseWriter, r *http.Request) { - if !s.requireCore(w) { - return - } - ib, err := s.core.GetInbox(r.Context(), chi.URLParam(r, "id")) - if handleErr(w, err) { - return - } - writeJSON(w, http.StatusOK, ib) -} - -// ingest accepts a raw RFC 5322 message body and stores it in the inbox — the -// milestone-1 seed path, and the same path a MailBackend uses for real inbound. -func (s *Server) ingest(w http.ResponseWriter, r *http.Request) { - if !s.requireCore(w) { - return - } - inboxID := chi.URLParam(r, "id") - if _, err := s.core.GetInbox(r.Context(), inboxID); handleErr(w, err) { - return - } - raw, err := io.ReadAll(io.LimitReader(r.Body, maxIngestBytes)) - if err != nil || len(raw) == 0 { - writeErr(w, http.StatusBadRequest, "invalid_request", "raw message body required") - return - } - msg, err := s.core.IngestRaw(r.Context(), inboxID, raw) - if handleErr(w, err) { - return - } - writeJSON(w, http.StatusCreated, msg) -} - -func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) { - if !s.requireCore(w) { - return - } - limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) - list, err := s.core.ListMessages(r.Context(), chi.URLParam(r, "id"), limit) - if handleErr(w, err) { - return - } - writeJSON(w, http.StatusOK, map[string]any{"messages": list}) -} - -func (s *Server) getMessage(w http.ResponseWriter, r *http.Request) { - if !s.requireCore(w) { - return - } - m, err := s.core.GetMessage(r.Context(), chi.URLParam(r, "id"), chi.URLParam(r, "msgID")) - if handleErr(w, err) { - return - } - writeJSON(w, http.StatusOK, m) -} - -func (s *Server) listThreads(w http.ResponseWriter, r *http.Request) { - if !s.requireCore(w) { - return - } - list, err := s.core.ListThreads(r.Context(), chi.URLParam(r, "id")) - if handleErr(w, err) { - return - } - writeJSON(w, http.StatusOK, map[string]any{"threads": list}) -} - -func (s *Server) getThread(w http.ResponseWriter, r *http.Request) { - if !s.requireCore(w) { - return - } - inboxID := chi.URLParam(r, "id") - threadID := chi.URLParam(r, "threadID") - t, err := s.core.GetThread(r.Context(), inboxID, threadID) - if handleErr(w, err) { - return - } - msgs, err := s.core.GetThreadMessages(r.Context(), inboxID, threadID) - if handleErr(w, err) { - return - } - writeJSON(w, http.StatusOK, map[string]any{"thread": t, "messages": msgs}) -} - -func (s *Server) sendMessage(w http.ResponseWriter, r *http.Request) { - if !s.requireCore(w) { - return - } - inboxID := chi.URLParam(r, "id") - ib, err := s.core.GetInbox(r.Context(), inboxID) - if handleErr(w, err) { - return - } - var body struct { - To []string `json:"to"` - Cc []string `json:"cc"` - Bcc []string `json:"bcc"` - Subject string `json:"subject"` - Text string `json:"text"` - HTML string `json:"html"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.To) == 0 { - writeErr(w, http.StatusBadRequest, "invalid_request", "at least one 'to' recipient is required") - return - } - s.dispatch(w, r, &mail.OutgoingMessage{ - InboxID: inboxID, From: ib.Address, - To: body.To, Cc: body.Cc, Bcc: body.Bcc, - Subject: body.Subject, Text: body.Text, HTML: body.HTML, - }) -} - -func (s *Server) replyMessage(w http.ResponseWriter, r *http.Request) { - if !s.requireCore(w) { - return - } - inboxID := chi.URLParam(r, "id") - ib, err := s.core.GetInbox(r.Context(), inboxID) - if handleErr(w, err) { - return - } - orig, err := s.core.GetMessage(r.Context(), inboxID, chi.URLParam(r, "msgID")) - if handleErr(w, err) { - return - } - var body struct { - Text string `json:"text"` - HTML string `json:"html"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeErr(w, http.StatusBadRequest, "invalid_request", "invalid body") - return - } - out := &mail.OutgoingMessage{ - InboxID: inboxID, From: ib.Address, - Subject: replySubject(orig.Subject), - Text: body.Text, HTML: body.HTML, - References: append(append([]string{}, orig.References...), strPtr(orig.MessageIDHdr)...), - } - if orig.MessageIDHdr != nil { - out.InReplyTo = *orig.MessageIDHdr - } - if orig.FromAddr != nil { - out.To = []string{*orig.FromAddr} - } - s.dispatch(w, r, out) -} - -// dispatch hands an outgoing message to the active backend, translating the -// NullBackend's ErrNotSupported into a clear 503 (no send path configured yet). -func (s *Server) dispatch(w http.ResponseWriter, r *http.Request, out *mail.OutgoingMessage) { - res, err := s.backend.Send(r.Context(), out) - if errors.Is(err, mail.ErrNotSupported) { - writeErr(w, http.StatusServiceUnavailable, "send_unavailable", - "no send-capable mail backend configured (relay/imap_smtp/embedded land in later milestones)") - return - } - if err != nil { - writeErr(w, http.StatusBadGateway, "send_failed", err.Error()) - return - } - writeJSON(w, http.StatusAccepted, res) -} - -func handleErr(w http.ResponseWriter, err error) bool { - var pgErr *pgconn.PgError - switch { - case err == nil: - return false - case errors.Is(err, core.ErrNotFound): - writeErr(w, http.StatusNotFound, "not_found", "resource not found") - case errors.As(err, &pgErr) && pgErr.Code == "22P02": - // invalid_text_representation, e.g. a malformed UUID in the path — treat - // as not found rather than a 500 that echoes the driver error. - writeErr(w, http.StatusNotFound, "not_found", "resource not found") - case errors.As(err, &pgErr) && pgErr.Code == "23505": - // unique_violation, e.g. an inbox address that already exists. - writeErr(w, http.StatusConflict, "conflict", "resource already exists") - default: - writeErr(w, http.StatusInternalServerError, "internal_error", "internal error") - } - return true -} - -func replySubject(s *string) string { - if s == nil || strings.TrimSpace(*s) == "" { - return "Re:" - } - trimmed := strings.TrimSpace(*s) - if strings.HasPrefix(strings.ToLower(trimmed), "re:") { - return trimmed - } - return "Re: " + trimmed -} - -func strPtr(s *string) []string { - if s == nil || *s == "" { - return nil - } - return []string{*s} -} diff --git a/internal/api/server.go b/internal/api/server.go deleted file mode 100644 index 8e89cf5..0000000 --- a/internal/api/server.go +++ /dev/null @@ -1,101 +0,0 @@ -// Package api is OpenMail's agent-facing HTTP surface: the AgentMail-shaped v0 -// REST API (see ARCHITECTURE.md §4). -package api - -import ( - "encoding/json" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - - "github.com/karti-ai/openmail/internal/config" - "github.com/karti-ai/openmail/internal/core" - "github.com/karti-ai/openmail/internal/mail" -) - -type Server struct { - cfg config.Config - core *core.Service // nil when DATABASE_URL is unset - backend mail.MailBackend // never nil (NullBackend by default) - podID string // default pod, resolved at startup -} - -func New(cfg config.Config, svc *core.Service, backend mail.MailBackend, podID string) *Server { - return &Server{cfg: cfg, core: svc, backend: backend, podID: podID} -} - -func (s *Server) Router() http.Handler { - r := chi.NewRouter() - r.Use(middleware.RequestID) - r.Use(middleware.RealIP) - r.Use(middleware.Recoverer) - - r.Get("/healthz", s.handleHealth) - - r.Group(func(r chi.Router) { - r.Use(s.bearerAuth) - - r.Route("/v0/inboxes", func(r chi.Router) { - r.Post("/", s.createInbox) - r.Get("/", s.listInboxes) - r.Get("/{id}", s.getInbox) - r.Post("/{id}/ingest", s.ingest) // seed/inbound path (milestone 1) - r.Post("/{id}/messages/send", s.sendMessage) - r.Get("/{id}/messages", s.listMessages) - r.Get("/{id}/messages/{msgID}", s.getMessage) - r.Post("/{id}/messages/{msgID}/reply", s.replyMessage) - r.Get("/{id}/threads", s.listThreads) - r.Get("/{id}/threads/{threadID}", s.getThread) - }) - }) - - return r -} - -func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - status := map[string]any{"status": "ok", "backend": backendName(s.backend)} - if s.core != nil { - if err := s.core.Ping(r.Context()); err != nil { - // /healthz is unauthenticated — don't leak DSN/host/internal details. - status["status"] = "degraded" - status["db"] = "error" - writeJSON(w, http.StatusServiceUnavailable, status) - return - } - status["db"] = "ok" - } else { - status["db"] = "not configured" - } - writeJSON(w, http.StatusOK, status) -} - -func backendName(b mail.MailBackend) string { - if _, ok := b.(mail.NullBackend); ok { - return "null" - } - return "configured" -} - -// requireCore guards handlers that need the database. Returns false (and writes -// a 503) when the store is unconfigured. -func (s *Server) requireCore(w http.ResponseWriter) bool { - if s.core == nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{ - "error": "db_unconfigured", - "message": "DATABASE_URL not set", - }) - return false - } - return true -} - -func writeJSON(w http.ResponseWriter, code int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(code) - _ = json.NewEncoder(w).Encode(v) -} - -func writeErr(w http.ResponseWriter, code int, errCode, msg string) { - writeJSON(w, code, map[string]string{"error": errCode, "message": msg}) -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 8cd3c02..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,30 +0,0 @@ -// Package config loads OpenMail configuration from the environment. -// Twelve-factor style: everything via env vars, sane defaults for local dev. -package config - -import ( - "os" -) - -type Config struct { - HTTPAddr string // OPENMAIL_HTTP_ADDR, e.g. ":8080" - DatabaseURL string // DATABASE_URL, e.g. postgres://user:pass@host:5432/openmail - SMTPAddr string // OPENMAIL_SMTP_ADDR, inbound :25 listener - AdminToken string // OPENMAIL_ADMIN_TOKEN, bootstrap bearer until DB-backed api_keys land -} - -func Load() Config { - return Config{ - HTTPAddr: envOr("OPENMAIL_HTTP_ADDR", ":8080"), - DatabaseURL: os.Getenv("DATABASE_URL"), - SMTPAddr: envOr("OPENMAIL_SMTP_ADDR", ":25"), - AdminToken: os.Getenv("OPENMAIL_ADMIN_TOKEN"), - } -} - -func envOr(key, def string) string { - if v := os.Getenv(key); v != "" { - return v - } - return def -} diff --git a/internal/core/core.go b/internal/core/core.go deleted file mode 100644 index 3cbc2fb..0000000 --- a/internal/core/core.go +++ /dev/null @@ -1,58 +0,0 @@ -// Package core holds OpenMail's domain services: inboxes, messages, threads, -// drafts, and the inbound ingest path (parse → thread → store). It is the layer -// the API and MCP server call, and it implements mail.InboundSink so any -// MailBackend can deliver into it. -package core - -import ( - "context" - "errors" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - - "github.com/karti-ai/openmail/internal/store" -) - -// ErrNotFound is returned when a requested resource does not exist. -var ErrNotFound = errors.New("core: not found") - -type Service struct { - pool *pgxpool.Pool -} - -func New(st *store.Store) *Service { return &Service{pool: st.Pool} } - -// Ping verifies the database connection (used by the health endpoint). -func (s *Service) Ping(ctx context.Context) error { return s.pool.Ping(ctx) } - -// EnsureDefaultPod returns the id of the singleton "default" pod, creating it if -// absent. Until DB-backed api_keys carry a pod_id, the API operates within this -// one tenant. Idempotent. -func (s *Service) EnsureDefaultPod(ctx context.Context) (string, error) { - var id string - err := s.pool.QueryRow(ctx, `SELECT id::text FROM pods WHERE name = 'default'`).Scan(&id) - if err == nil { - return id, nil - } - if !errors.Is(err, pgx.ErrNoRows) { - return "", err - } - err = s.pool.QueryRow(ctx, - `INSERT INTO pods (name) VALUES ('default') - ON CONFLICT (name) DO NOTHING - RETURNING id::text`).Scan(&id) - if errors.Is(err, pgx.ErrNoRows) { - // Lost a race; read the row the other writer created. - err = s.pool.QueryRow(ctx, `SELECT id::text FROM pods WHERE name = 'default'`).Scan(&id) - } - return id, err -} - -// nullStr maps an optional string to a value usable as a nullable SQL arg. -func nullStr(s *string) any { - if s == nil { - return nil - } - return *s -} diff --git a/internal/core/inbox.go b/internal/core/inbox.go deleted file mode 100644 index 0ff56e7..0000000 --- a/internal/core/inbox.go +++ /dev/null @@ -1,72 +0,0 @@ -package core - -import ( - "context" - "encoding/json" - "errors" - "time" - - "github.com/jackc/pgx/v5" -) - -type Inbox struct { - ID string `json:"id"` - PodID string `json:"pod_id"` - Address string `json:"address"` - DisplayName *string `json:"display_name,omitempty"` - Metadata json.RawMessage `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -const inboxCols = `id::text, pod_id::text, address, display_name, metadata, created_at, updated_at` - -func scanInbox(row pgx.Row) (Inbox, error) { - var ib Inbox - err := row.Scan(&ib.ID, &ib.PodID, &ib.Address, &ib.DisplayName, &ib.Metadata, &ib.CreatedAt, &ib.UpdatedAt) - return ib, err -} - -// CreateInbox provisions a new agent-owned address within a pod. -func (s *Service) CreateInbox(ctx context.Context, podID, address string, displayName *string) (Inbox, error) { - row := s.pool.QueryRow(ctx, - `INSERT INTO inboxes (pod_id, address, display_name) - VALUES ($1, $2, $3) - RETURNING `+inboxCols, - podID, address, nullStr(displayName)) - return scanInbox(row) -} - -func (s *Service) GetInbox(ctx context.Context, id string) (Inbox, error) { - ib, err := scanInbox(s.pool.QueryRow(ctx, `SELECT `+inboxCols+` FROM inboxes WHERE id = $1`, id)) - if errors.Is(err, pgx.ErrNoRows) { - return Inbox{}, ErrNotFound - } - return ib, err -} - -func (s *Service) GetInboxByAddress(ctx context.Context, address string) (Inbox, error) { - ib, err := scanInbox(s.pool.QueryRow(ctx, `SELECT `+inboxCols+` FROM inboxes WHERE address = $1`, address)) - if errors.Is(err, pgx.ErrNoRows) { - return Inbox{}, ErrNotFound - } - return ib, err -} - -func (s *Service) ListInboxes(ctx context.Context, podID string) ([]Inbox, error) { - rows, err := s.pool.Query(ctx, - `SELECT `+inboxCols+` FROM inboxes WHERE pod_id = $1 ORDER BY created_at DESC`, podID) - if err != nil { - return nil, err - } - defer rows.Close() - out := []Inbox{} - for rows.Next() { - ib, err := scanInbox(rows) - if err != nil { - return nil, err - } - out = append(out, ib) - } - return out, rows.Err() -} diff --git a/internal/core/message.go b/internal/core/message.go deleted file mode 100644 index 2abdfb9..0000000 --- a/internal/core/message.go +++ /dev/null @@ -1,383 +0,0 @@ -package core - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "log/slog" - "regexp" - "strings" - "time" - - "github.com/jackc/pgx/v5" - "github.com/mjl-/mox/message" -) - -type Message struct { - ID string `json:"id"` - InboxID string `json:"inbox_id"` - ThreadID *string `json:"thread_id,omitempty"` - MessageIDHdr *string `json:"message_id,omitempty"` - InReplyTo *string `json:"in_reply_to,omitempty"` - References []string `json:"references"` - FromAddr *string `json:"from,omitempty"` - ToAddrs []string `json:"to"` - Cc []string `json:"cc"` - Bcc []string `json:"bcc"` - Subject *string `json:"subject,omitempty"` - Preview *string `json:"preview,omitempty"` - Text *string `json:"text,omitempty"` - HTML *string `json:"html,omitempty"` - ExtractedText *string `json:"extracted_text,omitempty"` - Labels []string `json:"labels"` - SizeBytes *int64 `json:"size_bytes,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -const messageCols = `id::text, inbox_id::text, thread_id::text, message_id_hdr, in_reply_to, ` + - `"references", from_addr, to_addrs, cc, bcc, subject, preview, text, html, extracted_text, ` + - `labels, size_bytes, created_at` - -func scanMessage(row pgx.Row) (Message, error) { - var m Message - err := row.Scan(&m.ID, &m.InboxID, &m.ThreadID, &m.MessageIDHdr, &m.InReplyTo, - &m.References, &m.FromAddr, &m.ToAddrs, &m.Cc, &m.Bcc, &m.Subject, &m.Preview, - &m.Text, &m.HTML, &m.ExtractedText, &m.Labels, &m.SizeBytes, &m.CreatedAt) - return m, err -} - -// GetMessage is scoped to the inbox: a message id that belongs to another inbox -// returns ErrNotFound, preventing cross-inbox access. -func (s *Service) GetMessage(ctx context.Context, inboxID, id string) (Message, error) { - m, err := scanMessage(s.pool.QueryRow(ctx, - `SELECT `+messageCols+` FROM messages WHERE id = $1 AND inbox_id = $2`, id, inboxID)) - if errors.Is(err, pgx.ErrNoRows) { - return Message{}, ErrNotFound - } - return m, err -} - -func (s *Service) ListMessages(ctx context.Context, inboxID string, limit int) ([]Message, error) { - if limit <= 0 || limit > 200 { - limit = 50 - } - rows, err := s.pool.Query(ctx, - `SELECT `+messageCols+` FROM messages WHERE inbox_id = $1 ORDER BY created_at DESC LIMIT $2`, - inboxID, limit) - if err != nil { - return nil, err - } - defer rows.Close() - return collectMessages(rows) -} - -func (s *Service) GetThreadMessages(ctx context.Context, inboxID, threadID string) ([]Message, error) { - rows, err := s.pool.Query(ctx, - `SELECT `+messageCols+` FROM messages WHERE thread_id = $1 AND inbox_id = $2 ORDER BY created_at ASC`, - threadID, inboxID) - if err != nil { - return nil, err - } - defer rows.Close() - return collectMessages(rows) -} - -func collectMessages(rows pgx.Rows) ([]Message, error) { - out := []Message{} - for rows.Next() { - m, err := scanMessage(rows) - if err != nil { - return nil, err - } - out = append(out, m) - } - return out, rows.Err() -} - -// Deliver implements mail.InboundSink: resolve the inbox by address and ingest. -func (s *Service) Deliver(ctx context.Context, inboxAddr string, raw []byte) error { - ib, err := s.GetInboxByAddress(ctx, inboxAddr) - if err != nil { - return err - } - _, err = s.IngestRaw(ctx, ib.ID, raw) - return err -} - -// IngestRaw parses a raw RFC 5322 message with mox, resolves its thread, and -// stores it — the single inbound path shared by the ingest API and every -// MailBackend. Runs in one transaction. -func (s *Service) IngestRaw(ctx context.Context, inboxID string, raw []byte) (Message, error) { - pr, err := parseRaw(raw) - if err != nil { - return Message{}, err - } - extracted := stripQuotes(pr.text) - preview := makePreview(extracted, pr.text) - headersJSON, _ := json.Marshal(pr.headers) - - tx, err := s.pool.Begin(ctx) - if err != nil { - return Message{}, err - } - defer tx.Rollback(ctx) - - threadID, err := resolveThreadTx(ctx, tx, inboxID, pr) - if err != nil { - return Message{}, err - } - - var msgID string - err = tx.QueryRow(ctx, - `INSERT INTO messages - (inbox_id, thread_id, message_id_hdr, in_reply_to, "references", from_addr, - to_addrs, cc, bcc, subject, preview, text, html, extracted_text, - size_bytes, headers, ts) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16, - to_tsvector('english', coalesce($10,'') || ' ' || coalesce($12,''))) - RETURNING id::text`, - inboxID, threadID, nullEmpty(pr.messageID), nullEmpty(pr.inReplyTo), pr.references, - nullEmpty(pr.from), pr.to, pr.cc, pr.bcc, nullEmpty(pr.subject), nullEmpty(preview), - nullEmpty(pr.text), nullEmpty(pr.html), nullEmpty(extracted), - int64(len(raw)), headersJSON, - ).Scan(&msgID) - if err != nil { - return Message{}, err - } - - if _, err := tx.Exec(ctx, - `UPDATE threads - SET message_count = message_count + 1, - last_message_id = $1, - subject = COALESCE(subject, $2), - updated_at = now() - WHERE id = $3`, - msgID, nullEmpty(pr.subject), threadID); err != nil { - return Message{}, err - } - - evt, _ := json.Marshal(map[string]string{"message_id": msgID, "thread_id": threadID}) - if _, err := tx.Exec(ctx, - `INSERT INTO events (inbox_id, type, payload) VALUES ($1, 'message.received', $2)`, - inboxID, evt); err != nil { - return Message{}, err - } - - if err := tx.Commit(ctx); err != nil { - return Message{}, err - } - return s.GetMessage(ctx, inboxID, msgID) -} - -// resolveThreadTx finds the thread for a message via In-Reply-To/References -// (the correct, false-merge-safe mechanism), creating a new thread otherwise. -// Subject-based fallback is intentionally deferred (see ARCHITECTURE.md §9). -func resolveThreadTx(ctx context.Context, tx pgx.Tx, inboxID string, pr parsed) (string, error) { - cand := make([]string, 0, len(pr.references)+1) - if pr.inReplyTo != "" { - cand = append(cand, pr.inReplyTo) - } - cand = append(cand, pr.references...) - - if len(cand) > 0 { - var tid string - err := tx.QueryRow(ctx, - `SELECT thread_id::text FROM messages - WHERE inbox_id = $1 AND message_id_hdr = ANY($2) AND thread_id IS NOT NULL - ORDER BY created_at DESC LIMIT 1`, - inboxID, cand).Scan(&tid) - if err == nil { - return tid, nil - } - if !errors.Is(err, pgx.ErrNoRows) { - return "", err - } - } - - var tid string - err := tx.QueryRow(ctx, - `INSERT INTO threads (inbox_id, subject) VALUES ($1, $2) RETURNING id::text`, - inboxID, nullEmpty(pr.subject)).Scan(&tid) - return tid, err -} - -// --- parsing (mox/message) --- - -// parsed is the normalized result of parsing a raw message. -type parsed struct { - messageID string - inReplyTo string - references []string - from string - to []string - cc []string - bcc []string - subject string - text string - html string - headers map[string][]string -} - -var discardLog = slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})) - -func parseRaw(raw []byte) (parsed, error) { - var pr parsed - // EnsurePart always returns a usable Part — building an octet-stream fallback - // even when parsing hits a recoverable defect (bare CR/LF, bad Content-Type, - // missing boundary, truncated DSN). That tolerance for messy real-world mail - // is precisely why mox was chosen, so we proceed with the returned part and - // do NOT treat the recoverable error as fatal. - p, _ := message.EnsurePart(discardLog, false, bytes.NewReader(raw), int64(len(raw))) - if p.Envelope != nil { - e := p.Envelope - pr.subject = e.Subject - pr.messageID = e.MessageID - pr.inReplyTo = e.InReplyTo - pr.from = firstAddr(e.From) - pr.to = addrList(e.To) - pr.cc = addrList(e.CC) - pr.bcc = addrList(e.BCC) - } - if hdr, herr := p.Header(); herr == nil { - pr.references = strings.Fields(hdr.Get("References")) - pr.headers = hdr - } - if pr.references == nil { - pr.references = []string{} - } - pr.text, pr.html = extractBodies(&p) - return pr, nil -} - -// extractBodies walks the MIME tree and returns the first text/plain and -// text/html leaf bodies (coerced to valid UTF-8). It descends into embedded -// messages and skips attachment parts. -func extractBodies(p *message.Part) (text, html string) { - // Embedded message (message/rfc822 or message/global): the sub-message lives - // under p.Message, not p.Parts. Wire its reader, then recurse — otherwise - // forwarded mail and DSN/bounce bodies are lost. - if p.Message != nil { - if err := p.SetMessageReaderAt(); err == nil { - return extractBodies(p.Message) - } - return "", "" - } - if len(p.Parts) == 0 { - if isAttachment(p) { - return "", "" // an attachment is not the message body - } - body := readBody(p) - switch { - case p.MediaType == "TEXT" && p.MediaSubType == "HTML": - return "", body - case p.MediaType == "TEXT" || p.MediaType == "": - return body, "" // PLAIN, or absent content-type → treat as plain - default: - return "", "" - } - } - for i := range p.Parts { - t, h := extractBodies(&p.Parts[i]) - if text == "" { - text = t - } - if html == "" { - html = h - } - } - return text, html -} - -// isAttachment reports whether a part is declared as an attachment (so it is not -// treated as the message body). Content-Disposition carries params, so we match -// the leading token. -func isAttachment(p *message.Part) bool { - if p.ContentDisposition == nil { - return false - } - return strings.HasPrefix(strings.ToLower(strings.TrimSpace(*p.ContentDisposition)), "attachment") -} - -const maxBodyBytes = 2 << 20 // 2 MiB cap per body part for milestone 1 - -func readBody(p *message.Part) string { - rd := p.ReaderUTF8OrBinary() - if rd == nil { - return "" - } - var b strings.Builder - _, _ = io.Copy(&b, io.LimitReader(rd, maxBodyBytes)) - // Bodies may be non-UTF-8 (mox returns raw bytes for unknown/empty charsets) - // and LimitReader can cut mid-rune; Postgres text/tsvector reject invalid - // UTF-8 and would roll back the whole ingest. Coerce to valid UTF-8. - return strings.ToValidUTF8(b.String(), "�") -} - -// firstAddr returns the first address that has both a localpart and a host. mox -// appends empty-User/Host entries for addresses it cannot parse; emitting "@" -// for those would be wrong, so we skip them. -func firstAddr(as []message.Address) string { - for _, a := range as { - if a.User != "" && a.Host != "" { - return a.User + "@" + a.Host - } - } - return "" -} - -func addrList(as []message.Address) []string { - out := make([]string, 0, len(as)) - for _, a := range as { - if a.User == "" || a.Host == "" { - continue - } - out = append(out, a.User+"@"+a.Host) - } - return out -} - -// --- text helpers --- - -var onWroteRe = regexp.MustCompile(`(?i)^on .+wrote:$`) - -// stripQuotes removes quoted history so an agent reads only the new content. -// Milestone-1 heuristic (talon-style port deferred): cut at the first quoted -// block or "On … wrote:" attribution line. -func stripQuotes(text string) string { - if text == "" { - return "" - } - lines := strings.Split(text, "\n") - out := make([]string, 0, len(lines)) - for _, ln := range lines { - t := strings.TrimSpace(ln) - if strings.HasPrefix(t, ">") || onWroteRe.MatchString(t) { - break - } - out = append(out, ln) - } - return strings.TrimSpace(strings.Join(out, "\n")) -} - -func makePreview(extracted, full string) string { - src := extracted - if src == "" { - src = full - } - src = strings.Join(strings.Fields(src), " ") - const max = 200 - if len([]rune(src)) > max { - src = string([]rune(src)[:max]) - } - return src -} - -// nullEmpty maps "" to a SQL NULL so optional text columns stay null, not blank. -func nullEmpty(s string) any { - if s == "" { - return nil - } - return s -} diff --git a/internal/core/thread.go b/internal/core/thread.go deleted file mode 100644 index 1efefc9..0000000 --- a/internal/core/thread.go +++ /dev/null @@ -1,57 +0,0 @@ -package core - -import ( - "context" - "errors" - "time" - - "github.com/jackc/pgx/v5" -) - -type Thread struct { - ID string `json:"id"` - InboxID string `json:"inbox_id"` - Subject *string `json:"subject,omitempty"` - LastMessageID *string `json:"last_message_id,omitempty"` - MessageCount int `json:"message_count"` - Labels []string `json:"labels"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -const threadCols = `id::text, inbox_id::text, subject, last_message_id::text, message_count, labels, created_at, updated_at` - -func scanThread(row pgx.Row) (Thread, error) { - var t Thread - err := row.Scan(&t.ID, &t.InboxID, &t.Subject, &t.LastMessageID, &t.MessageCount, &t.Labels, &t.CreatedAt, &t.UpdatedAt) - return t, err -} - -func (s *Service) ListThreads(ctx context.Context, inboxID string) ([]Thread, error) { - rows, err := s.pool.Query(ctx, - `SELECT `+threadCols+` FROM threads WHERE inbox_id = $1 ORDER BY updated_at DESC`, inboxID) - if err != nil { - return nil, err - } - defer rows.Close() - out := []Thread{} - for rows.Next() { - t, err := scanThread(rows) - if err != nil { - return nil, err - } - out = append(out, t) - } - return out, rows.Err() -} - -// GetThread is scoped to the inbox: a thread id belonging to another inbox -// returns ErrNotFound. -func (s *Service) GetThread(ctx context.Context, inboxID, id string) (Thread, error) { - t, err := scanThread(s.pool.QueryRow(ctx, - `SELECT `+threadCols+` FROM threads WHERE id = $1 AND inbox_id = $2`, id, inboxID)) - if errors.Is(err, pgx.ErrNoRows) { - return Thread{}, ErrNotFound - } - return t, err -} diff --git a/internal/mail/backend.go b/internal/mail/backend.go deleted file mode 100644 index 6c2d51c..0000000 --- a/internal/mail/backend.go +++ /dev/null @@ -1,77 +0,0 @@ -// Package mail defines the MailBackend abstraction (ARCHITECTURE.md §0.2): all -// inbound delivery and outbound sending sit behind one interface so the core -// (API, MCP, store, threading) never depends on *how* mail moves. Concrete -// backends — relay, imap_smtp, embedded — live in subpackages and are added in -// later milestones. Milestone 1 ships only NullBackend. -package mail - -import ( - "context" - "errors" -) - -// OutgoingMessage is a message the core wants sent. The backend is responsible -// for building/serializing and (where applicable) DKIM-signing it. -type OutgoingMessage struct { - InboxID string - From string - To []string - Cc []string - Bcc []string - Subject string - Text string - HTML string - InReplyTo string - References []string -} - -// SendResult reports the outcome of a Send. -type SendResult struct { - MessageIDHdr string // RFC 5322 Message-ID assigned to the sent message - Accepted bool -} - -// InboundSink receives raw RFC 5322 messages a backend has accepted for an -// address. The core implements this (parse → thread → store → events). -type InboundSink interface { - Deliver(ctx context.Context, inboxAddr string, raw []byte) error -} - -// Caps advertises what a backend can do, so the API/MCP can expose accurate -// capabilities (e.g. whether throwaway addresses or custom domains are possible). -type Caps struct { - SelfHost bool // runs its own MTA in-process - InboundPush bool // delivers inbound without polling (webhook or :25) - CustomDomain bool // can own an arbitrary domain - ThrowawayAddrs bool // can mint addresses on demand -} - -// MailBackend abstracts where mail comes from and how it leaves. -type MailBackend interface { - // Send dispatches an outgoing message. - Send(ctx context.Context, msg *OutgoingMessage) (SendResult, error) - // Start delivers inbound messages to sink until ctx is cancelled. - Start(ctx context.Context, sink InboundSink) error - // Capabilities describes what this backend supports. - Capabilities() Caps -} - -// ErrNotSupported is returned by backends for operations they cannot perform. -var ErrNotSupported = errors.New("mail: operation not supported by this backend") - -// NullBackend satisfies MailBackend without moving any mail. It is the -// milestone-1 default: messages enter only via the ingest API (core acts as its -// own InboundSink), and sending is unavailable until a real backend is wired. -type NullBackend struct{} - -func (NullBackend) Send(context.Context, *OutgoingMessage) (SendResult, error) { - return SendResult{}, ErrNotSupported -} - -// Start blocks until cancelled; the null backend never produces inbound mail. -func (NullBackend) Start(ctx context.Context, _ InboundSink) error { - <-ctx.Done() - return ctx.Err() -} - -func (NullBackend) Capabilities() Caps { return Caps{} } diff --git a/internal/store/migrations/0001_init.sql b/internal/store/migrations/0001_init.sql deleted file mode 100644 index 2139a77..0000000 --- a/internal/store/migrations/0001_init.sql +++ /dev/null @@ -1,151 +0,0 @@ --- OpenMail initial schema. See ARCHITECTURE.md §3. --- Native, agent-shaped data model (not Mox's per-account bbolt index). - -CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid() - --- Tenant isolation. -CREATE TABLE IF NOT EXISTS pods ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - name text NOT NULL, - created_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (name) -); - --- Per-domain DKIM keys + DNS verification state. -CREATE TABLE IF NOT EXISTS domains ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE, - name text NOT NULL, - dkim_selector text, - dkim_privkey_ref text, -- pointer to key in secret store; never the key itself - verified boolean NOT NULL DEFAULT false, - created_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (name) -); - --- An agent-owned address; first-class API resource. -CREATE TABLE IF NOT EXISTS inboxes ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE, - address text NOT NULL, - display_name text, - metadata jsonb NOT NULL DEFAULT '{}'::jsonb, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (address) -); - -CREATE TABLE IF NOT EXISTS threads ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - inbox_id uuid NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE, - subject text, - last_message_id uuid, - message_count integer NOT NULL DEFAULT 0, - labels text[] NOT NULL DEFAULT '{}', - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS messages ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - inbox_id uuid NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE, - thread_id uuid REFERENCES threads(id) ON DELETE SET NULL, - message_id_hdr text, -- RFC 5322 Message-ID - in_reply_to text, - "references" text[] NOT NULL DEFAULT '{}', - from_addr text, - to_addrs text[] NOT NULL DEFAULT '{}', - cc text[] NOT NULL DEFAULT '{}', - bcc text[] NOT NULL DEFAULT '{}', - subject text, - preview text, - text text, - html text, - extracted_text text, -- quoted history stripped - extracted_html text, - raw_object_key text, -- pointer to raw .eml in object store - spf text, -- inbound auth verdicts (from mox pkgs) - dkim text, - dmarc text, - junk_score real, - labels text[] NOT NULL DEFAULT '{}', - size_bytes bigint, - headers jsonb NOT NULL DEFAULT '{}'::jsonb, - ts tsvector, -- full-text search - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS attachments ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - filename text, - content_type text, - size_bytes bigint, - object_key text NOT NULL, - inline boolean NOT NULL DEFAULT false, - content_id text -); - -CREATE TABLE IF NOT EXISTS drafts ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - inbox_id uuid NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE, - thread_id uuid REFERENCES threads(id) ON DELETE SET NULL, - to_addrs text[] NOT NULL DEFAULT '{}', - cc text[] NOT NULL DEFAULT '{}', - bcc text[] NOT NULL DEFAULT '{}', - subject text, - text text, - html text, - send_at timestamptz, - client_id text, - created_at timestamptz NOT NULL DEFAULT now() -); - --- Bearer tokens; only the hash is stored. -CREATE TABLE IF NOT EXISTS api_keys ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE, - hash bytea NOT NULL, - scopes text[] NOT NULL DEFAULT '{}', - created_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (hash) -); - -CREATE TABLE IF NOT EXISTS webhooks ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE, - url text NOT NULL, - event_types text[] NOT NULL DEFAULT '{}', - secret text NOT NULL, - created_at timestamptz NOT NULL DEFAULT now() -); - --- Outbound send queue + retries. -CREATE TABLE IF NOT EXISTS outbox ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE, - status text NOT NULL DEFAULT 'queued', -- queued|sending|sent|failed - attempts integer NOT NULL DEFAULT 0, - last_error text, - next_attempt_at timestamptz NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS events ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - inbox_id uuid REFERENCES inboxes(id) ON DELETE CASCADE, - type text NOT NULL, - payload jsonb NOT NULL DEFAULT '{}'::jsonb, - created_at timestamptz NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_inboxes_pod ON inboxes(pod_id); -CREATE INDEX IF NOT EXISTS idx_threads_inbox ON threads(inbox_id); -CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id); -CREATE INDEX IF NOT EXISTS idx_messages_inbox_time ON messages(inbox_id, created_at DESC); --- Supports per-delivery thread resolution (message_id_hdr = ANY(...) per inbox). -CREATE INDEX IF NOT EXISTS idx_messages_msgid ON messages(inbox_id, message_id_hdr) WHERE message_id_hdr IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages USING gin(ts); -CREATE INDEX IF NOT EXISTS idx_messages_labels ON messages USING gin(labels); -CREATE INDEX IF NOT EXISTS idx_threads_labels ON threads USING gin(labels); -CREATE INDEX IF NOT EXISTS idx_outbox_due ON outbox(next_attempt_at) WHERE status = 'queued'; diff --git a/internal/store/store.go b/internal/store/store.go deleted file mode 100644 index 5a58424..0000000 --- a/internal/store/store.go +++ /dev/null @@ -1,100 +0,0 @@ -// Package store owns OpenMail's Postgres-backed persistence. This is the -// deliberate divergence from Mox: OpenMail keeps its own native, agent-shaped -// data model (see ARCHITECTURE.md §3) rather than embedding Mox's bstore/bbolt -// account store. -package store - -import ( - "context" - "embed" - "fmt" - "sort" - "strings" - - "github.com/jackc/pgx/v5/pgxpool" -) - -//go:embed migrations/*.sql -var migrationsFS embed.FS - -type Store struct { - Pool *pgxpool.Pool -} - -// Open connects to Postgres and verifies the connection. -func Open(ctx context.Context, databaseURL string) (*Store, error) { - if databaseURL == "" { - return nil, fmt.Errorf("store: DATABASE_URL is empty") - } - pool, err := pgxpool.New(ctx, databaseURL) - if err != nil { - return nil, fmt.Errorf("store: connect: %w", err) - } - if err := pool.Ping(ctx); err != nil { - pool.Close() - return nil, fmt.Errorf("store: ping: %w", err) - } - return &Store{Pool: pool}, nil -} - -func (s *Store) Close() { - if s.Pool != nil { - s.Pool.Close() - } -} - -// Migrate applies any embedded migrations not yet recorded in schema_migrations, -// in filename order. Each migration runs in its own transaction. -func (s *Store) Migrate(ctx context.Context) error { - _, err := s.Pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( - version text PRIMARY KEY, - applied_at timestamptz NOT NULL DEFAULT now() - )`) - if err != nil { - return fmt.Errorf("migrate: ensure schema_migrations: %w", err) - } - - entries, err := migrationsFS.ReadDir("migrations") - if err != nil { - return fmt.Errorf("migrate: read embedded migrations: %w", err) - } - names := make([]string, 0, len(entries)) - for _, e := range entries { - if strings.HasSuffix(e.Name(), ".sql") { - names = append(names, e.Name()) - } - } - sort.Strings(names) - - for _, name := range names { - var exists bool - if err := s.Pool.QueryRow(ctx, - `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=$1)`, name, - ).Scan(&exists); err != nil { - return fmt.Errorf("migrate: check %s: %w", name, err) - } - if exists { - continue - } - sqlBytes, err := migrationsFS.ReadFile("migrations/" + name) - if err != nil { - return fmt.Errorf("migrate: read %s: %w", name, err) - } - tx, err := s.Pool.Begin(ctx) - if err != nil { - return fmt.Errorf("migrate: begin %s: %w", name, err) - } - if _, err := tx.Exec(ctx, string(sqlBytes)); err != nil { - _ = tx.Rollback(ctx) - return fmt.Errorf("migrate: apply %s: %w", name, err) - } - if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES($1)`, name); err != nil { - _ = tx.Rollback(ctx) - return fmt.Errorf("migrate: record %s: %w", name, err) - } - if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("migrate: commit %s: %w", name, err) - } - } - return nil -} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..8565ca6 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.97.1" +components = ["rustfmt", "clippy"] diff --git a/spike/mimecheck/main.go b/spike/mimecheck/main.go deleted file mode 100644 index d26c53f..0000000 --- a/spike/mimecheck/main.go +++ /dev/null @@ -1,123 +0,0 @@ -// Command mimecheck is a throwaway feasibility spike for OpenMail's core -// architectural bet (see ARCHITECTURE.md §0, §9): can Mox's `message` package -// parse a raw RFC 5322 message standalone — outside Mox's store/config/global -// state — given only an io.ReaderAt? -// -// If this builds and runs without dragging in mox-/store/config, "embed Mox as -// a library" (Option B) is viable. Run: go run ./spike/mimecheck -package main - -import ( - "bytes" - "fmt" - "log/slog" - "os" - "strings" - - "github.com/mjl-/mox/message" -) - -// A deliberately messy real-world-ish message: multipart/alternative (text+html) -// with a reply quote, threading headers, and an attachment part. -const sampleEML = "From: Alice \r\n" + - "To: agent@openmail.test\r\n" + - "Subject: Re: invoice #42\r\n" + - "Message-ID: \r\n" + - "In-Reply-To: \r\n" + - "References: \r\n" + - "Date: Mon, 21 Jun 2026 12:00:00 +0000\r\n" + - "MIME-Version: 1.0\r\n" + - "Content-Type: multipart/mixed; boundary=\"OUTER\"\r\n" + - "\r\n" + - "--OUTER\r\n" + - "Content-Type: multipart/alternative; boundary=\"INNER\"\r\n" + - "\r\n" + - "--INNER\r\n" + - "Content-Type: text/plain; charset=utf-8\r\n" + - "\r\n" + - "Thanks, looks good to me.\r\n" + - "\r\n" + - "On Mon, Alice wrote:\r\n" + - "> here is the invoice\r\n" + - "--INNER\r\n" + - "Content-Type: text/html; charset=utf-8\r\n" + - "\r\n" + - "

Thanks, looks good to me.

\r\n" + - "--INNER--\r\n" + - "--OUTER\r\n" + - "Content-Type: application/pdf; name=\"invoice.pdf\"\r\n" + - "Content-Disposition: attachment; filename=\"invoice.pdf\"\r\n" + - "Content-Transfer-Encoding: base64\r\n" + - "\r\n" + - "JVBERi0xLjQK\r\n" + - "--OUTER--\r\n" - -func main() { - log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) - r := bytes.NewReader([]byte(sampleEML)) - - // EnsurePart fully parses header + walks the MIME tree given the size. - p, err := message.EnsurePart(log, false, r, int64(len(sampleEML))) - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: parse error: %v\n", err) - os.Exit(1) - } - - fmt.Println("=== mox/message standalone parse OK ===") - if p.Envelope != nil { - e := p.Envelope - fmt.Printf("Subject: %s\n", e.Subject) - fmt.Printf("MessageID: %s\n", e.MessageID) - fmt.Printf("InReplyTo: %s\n", e.InReplyTo) - if len(e.From) > 0 { - fmt.Printf("From: %s@%s\n", e.From[0].User, e.From[0].Host) - } - } - fmt.Printf("Top type: %s/%s\n", p.MediaType, p.MediaSubType) - - // Walk the tree, summarizing each leaf — proves multipart traversal works. - var textBody string - var attachments int - var walk func(parts []message.Part, depth int) - walk = func(parts []message.Part, depth int) { - for i := range parts { - sp := &parts[i] - indent := strings.Repeat(" ", depth) - disp := "" - if sp.ContentDisposition != nil { - disp = " [" + *sp.ContentDisposition + "]" - } - fmt.Printf("%s- %s/%s%s\n", indent, sp.MediaType, sp.MediaSubType, disp) - if sp.ContentDisposition != nil && strings.EqualFold(*sp.ContentDisposition, "attachment") { - attachments++ - } - if sp.MediaType == "TEXT" && sp.MediaSubType == "PLAIN" && textBody == "" { - if buf, rerr := readPart(sp); rerr == nil { - textBody = string(buf) - } - } - walk(sp.Parts, depth+1) - } - } - fmt.Println("Structure:") - walk(p.Parts, 1) - - fmt.Printf("\nAttachments found: %d\n", attachments) - fmt.Printf("text/plain body:\n%s\n", indentBlock(textBody)) - fmt.Println("=== SPIKE PASSED: Mox message parsing is usable standalone ===") -} - -func readPart(p *message.Part) ([]byte, error) { - rd := p.Reader() - var b bytes.Buffer - _, err := b.ReadFrom(rd) - return b.Bytes(), err -} - -func indentBlock(s string) string { - out := []string{} - for _, line := range strings.Split(strings.TrimRight(s, "\r\n"), "\n") { - out = append(out, " | "+strings.TrimRight(line, "\r")) - } - return strings.Join(out, "\n") -}