├── .gitignore ├── src ├── lib.rs ├── connection.rs ├── cli.rs ├── mailpace.rs ├── compression.rs ├── main.rs ├── tls.rs └── mime.rs ├── .vscode └── tasks.json ├── .env.example ├── Cargo.toml ├── debug-cargo.sh ├── test-lock.sh ├── test_cert.pem ├── docker-compose.yml ├── test_key.pem ├── basic_test.sh ├── Dockerfile ├── .config └── nextest.toml ├── justfile ├── .github └── workflows │ └── ci.yml ├── tests ├── common.rs ├── mailpace_tests.rs ├── html_compression_tests.rs ├── performance_tests.rs └── integration_tests.rs ├── docker-run.sh ├── test-docker-multiport.sh ├── TESTING.md ├── test.sh ├── DOCKER.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | Cargo.lock 3 | .env 4 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod cli; 2 | pub mod compression; 3 | pub mod connection; 4 | pub mod mailpace; 5 | pub mod mime; 6 | pub mod smtp; 7 | pub mod tls; 8 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "type": "shell", 7 | "command": "cargo", 8 | "args": [ 9 | "build" 10 | ], 11 | "group": "build", 12 | "problemMatcher": [ 13 | "$rustc" 14 | ] 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # MailPace API Configuration 2 | # Get your API token from: https://app.mailpace.com/domain_settings 3 | MAILPACE_API_TOKEN=your_mailpace_api_token_here 4 | 5 | # Optional: Custom MailPace endpoint (default: https://app.mailpace.com/api/v1/send) 6 | # MAILPACE_ENDPOINT=https://app.mailpace.com/api/v1/send 7 | -------------------------------------------------------------------------------- /src/connection.rs: -------------------------------------------------------------------------------- 1 | use tokio::io::AsyncWriteExt; 2 | use tokio::net::TcpStream; 3 | use tokio_rustls::server::TlsStream; 4 | 5 | pub enum Connection { 6 | Plain(TcpStream), 7 | Tls(Box>), 8 | } 9 | 10 | impl Connection { 11 | pub async fn write_all(&mut self, buf: &[u8]) -> tokio::io::Result<()> { 12 | match self { 13 | Connection::Plain(stream) => stream.write_all(buf).await, 14 | Connection::Tls(stream) => stream.write_all(buf).await, 15 | } 16 | } 17 | 18 | pub async fn flush(&mut self) -> tokio::io::Result<()> { 19 | match self { 20 | Connection::Plain(stream) => stream.flush().await, 21 | Connection::Tls(stream) => stream.flush().await, 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "vibe-gateway" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | name = "vibe_gateway" 8 | path = "src/lib.rs" 9 | 10 | [dependencies] 11 | tokio = { version = "1.0", features = ["full"] } 12 | tokio-rustls = "0.24" 13 | rustls = "0.21" 14 | rustls-pemfile = "1.0" 15 | serde = { version = "1.0", features = ["derive"] } 16 | serde_json = "1.0" 17 | reqwest = { version = "0.11", features = ["json"] } 18 | base64 = "0.21" 19 | anyhow = "1.0" 20 | tracing = "0.1" 21 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 22 | clap = { version = "4.0", features = ["derive", "env"] } 23 | minify-html = "0.15" 24 | 25 | [dev-dependencies] 26 | tokio-test = "0.4" 27 | wiremock = "0.5" 28 | lettre = "0.11" 29 | uuid = { version = "1.0", features = ["v4"] } 30 | futures = "0.3" 31 | rcgen = "0.12" 32 | -------------------------------------------------------------------------------- /debug-cargo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Debug script to understand the cargo lock issue 4 | 5 | set -e 6 | 7 | echo "=== Debugging cargo lock issue ===" 8 | 9 | echo "1. Checking for existing cargo processes..." 10 | ps aux | grep -i cargo | grep -v grep || echo "No cargo processes found" 11 | 12 | echo "2. Checking for lock files..." 13 | find . -name "*.lock" -type f | head -10 14 | 15 | echo "3. Checking target directory..." 16 | ls -la target/ || echo "No target directory" 17 | 18 | echo "4. Running simple cargo check..." 19 | timeout 30 cargo check || echo "Cargo check failed or timed out" 20 | 21 | echo "5. Trying to build..." 22 | timeout 60 cargo build --release || echo "Cargo build failed or timed out" 23 | 24 | echo "6. Checking if binary exists..." 25 | ls -la target/release/vibe-gateway || echo "Binary not found" 26 | 27 | echo "=== Debug complete ===" 28 | -------------------------------------------------------------------------------- /test-lock.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Test synchronization helper to prevent cargo lock conflicts 4 | 5 | set -e 6 | 7 | LOCK_FILE="/tmp/vibe-gateway-test.lock" 8 | 9 | # Function to acquire lock 10 | acquire_lock() { 11 | local timeout=300 # 5 minutes timeout 12 | local elapsed=0 13 | 14 | while [ $elapsed -lt $timeout ]; do 15 | if (set -C; echo $$ > "$LOCK_FILE") 2>/dev/null; then 16 | return 0 17 | fi 18 | sleep 1 19 | elapsed=$((elapsed + 1)) 20 | done 21 | 22 | echo "Failed to acquire lock after $timeout seconds" 23 | return 1 24 | } 25 | 26 | # Function to release lock 27 | release_lock() { 28 | rm -f "$LOCK_FILE" 29 | } 30 | 31 | # Trap to ensure lock is released on exit 32 | trap release_lock EXIT 33 | 34 | # Main function 35 | main() { 36 | echo "Acquiring test lock..." 37 | acquire_lock 38 | 39 | echo "Running test command: $*" 40 | exec "$@" 41 | } 42 | 43 | main "$@" 44 | -------------------------------------------------------------------------------- /test_cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDdTCCAl2gAwIBAgIUfw4v8/+LStr7ZUjtenjJzPhjSScwDQYJKoZIhvcNAQEL 3 | BQAwSjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjENMAsG 4 | A1UECgwEVGVzdDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MDcxNjE2MTkwNVoX 5 | DTI2MDcxNjE2MTkwNVowSjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYD 6 | VQQHDAJTRjENMAsGA1UECgwEVGVzdDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjAN 7 | BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA52l5m6sRsNxpyOdMIFWptV+2LQ18 8 | JIghJ9CDajnxOkSsGjOnyFDJyP6dKaLTG8hLdWdns8qQmDXwFq4PfuucjF6fCaun 9 | tHKpNVBtq8bVFTBOYBCxXYdSqXiMRAlI1sm68+ZzT9vJQgr6RQdU89YCZfdA5Zg5 10 | Ck5wz1LvrmFGS5pc0uQRFOkwnVutfbG2izg6BTMqsBbLK3wRxler86DpeJOdqUQE 11 | CoZRt2kdgSnLJZ0RSLfMUNv7rq3KpxiJZpExlCFQFNZ296+2YjnUmCQJfM35cuW9 12 | Y0D1yZIZsSfs7MTCX0OFepxso1S7u09XpqRmUWKY9GYywlPuqKhZ0PmhOwIDAQAB 13 | o1MwUTAdBgNVHQ4EFgQU93+5gh/FRLx53y10PKMdKb+bB1owHwYDVR0jBBgwFoAU 14 | 93+5gh/FRLx53y10PKMdKb+bB1owDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B 15 | AQsFAAOCAQEAsTqb7rqwmLZIOL3a6mKSpLKAySKrT/mh1xmCvFien5vWps39q2dA 16 | 8cppTu37a/FEswdEK4KD2yfYJk5mP6yRXweb5kqmGxEadCgOknlRTSoiU9DcewRw 17 | tkQAVG7CaoQj65+yC3hGi6+Bc/r0sk37ARsKgdtjJZB/9yhz3/qWZniFe5XKRiXI 18 | 6Xa4zS4S51rfZ/JjRcl5tLrOfsjO5DncAMHGv7aEsUP+HLowwMkWN/TeLfiCKV86 19 | RLeqI9EgYdp4PAD/iiFduUeauC5MhkjGJ6dE5kkk2bgpLj7Ns7BPx5NNrn2D6qQe 20 | U4OSdiUImYMGKFhCwX+4JIBEkYZ5a6qISg== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | 3 | #[derive(Parser, Debug)] 4 | #[command(author, version, about, long_about = None)] 5 | pub struct Args { 6 | /// SMTP server listen address (single port mode) 7 | #[arg(short, long, default_value = "127.0.0.1:2525")] 8 | pub listen: String, 9 | 10 | /// Enable multi-port Docker mode (ports 25, 587, 2525 with STARTTLS, 465 with implicit TLS) 11 | #[arg(long)] 12 | pub docker_multi_port: bool, 13 | 14 | /// MailPace API endpoint 15 | #[arg(long, default_value = "https://app.mailpace.com/api/v1/send")] 16 | pub mailpace_endpoint: String, 17 | 18 | /// Default MailPace API token (optional, can be overridden by SMTP auth) 19 | #[arg(long, env = "MAILPACE_API_TOKEN")] 20 | pub default_mailpace_token: Option, 21 | 22 | /// Enable TLS/STARTTLS support 23 | #[arg(long)] 24 | pub enable_tls: bool, 25 | 26 | /// Debug mode 27 | #[arg(short, long)] 28 | pub debug: bool, 29 | 30 | /// Enable attachment support 31 | #[arg(long)] 32 | pub enable_attachments: bool, 33 | 34 | /// Maximum attachment size in bytes (default: 10MB) 35 | #[arg(long, default_value = "10485760")] 36 | pub max_attachment_size: usize, 37 | 38 | /// Maximum number of attachments per email (default: 10) 39 | #[arg(long, default_value = "10")] 40 | pub max_attachments: usize, 41 | 42 | /// Enable HTML compression for email bodies 43 | #[arg(long)] 44 | pub enable_html_compression: bool, 45 | } 46 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | vibe-gateway: 5 | build: . 6 | ports: 7 | # Standard SMTP port with STARTTLS 8 | - "25:25" 9 | # Message Submission port with STARTTLS 10 | - "587:587" 11 | # Alternative SMTP port with STARTTLS 12 | - "2525:2525" 13 | # SMTP over SSL (implicit TLS) 14 | - "465:465" 15 | environment: 16 | # Set your MailPace API token here or via .env file 17 | - MAILPACE_API_TOKEN=${MAILPACE_API_TOKEN:-} 18 | volumes: 19 | # Mount your own TLS certificates if you have them 20 | # - ./your_cert.pem:/app/test_cert.pem:ro 21 | # - ./your_key.pem:/app/test_key.pem:ro 22 | restart: unless-stopped 23 | healthcheck: 24 | test: ["CMD", "timeout", "5", "bash", "-c", "&1 | grep -q "SMTP server listening" && echo "✓ Server startup works" || echo "✗ Server startup failed" 20 | 21 | # Test 3: Check that required files exist 22 | echo "Test 3: Required files" 23 | if [[ -f "./Cargo.toml" && -f "./src/main.rs" && -f "./TESTING.md" ]]; then 24 | echo "✓ Required files exist" 25 | else 26 | echo "✗ Required files missing" 27 | exit 1 28 | fi 29 | 30 | # Test 4: Check that test files exist 31 | echo "Test 4: Test files" 32 | if [[ -f "./tests/integration_tests.rs" && -f "./tests/common.rs" && -f "./tests/mailpace_tests.rs" ]]; then 33 | echo "✓ Test files exist" 34 | else 35 | echo "✗ Test files missing" 36 | exit 1 37 | fi 38 | 39 | # Test 5: Check that CI file exists 40 | echo "Test 5: CI configuration" 41 | if [[ -f "./.github/workflows/ci.yml" ]]; then 42 | echo "✓ CI configuration exists" 43 | else 44 | echo "✗ CI configuration missing" 45 | exit 1 46 | fi 47 | 48 | echo "=== All basic tests passed! ===" 49 | echo "" 50 | echo "Next steps:" 51 | echo "1. Run './test.sh' to run the full test suite" 52 | echo "2. Run 'cargo test' to run all tests manually" 53 | echo "3. Check the CI pipeline in GitHub Actions" 54 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM rust:1.88-slim as builder 3 | 4 | # Install required dependencies 5 | RUN apt-get update && apt-get install -y \ 6 | pkg-config \ 7 | libssl-dev \ 8 | && rm -rf /var/lib/apt/lists/* 9 | 10 | # Set working directory 11 | WORKDIR /app 12 | 13 | # Copy Cargo.toml and Cargo.lock 14 | COPY Cargo.toml ./ 15 | 16 | # Copy source code 17 | COPY src ./src 18 | 19 | # Build the application (this will regenerate Cargo.lock if needed) 20 | RUN cargo build --release 21 | 22 | # Runtime stage 23 | FROM debian:bookworm-slim 24 | 25 | # Install required runtime dependencies 26 | RUN apt-get update && apt-get install -y \ 27 | ca-certificates \ 28 | libssl3 \ 29 | && rm -rf /var/lib/apt/lists/* 30 | 31 | # Create non-root user 32 | RUN useradd -r -s /bin/false -m vibe-gateway 33 | 34 | # Set working directory 35 | WORKDIR /app 36 | 37 | # Copy the binary from builder stage 38 | COPY --from=builder /app/target/release/vibe-gateway ./ 39 | 40 | # Copy TLS certificates 41 | COPY test_cert.pem test_key.pem ./ 42 | 43 | # Change ownership to non-root user 44 | RUN chown -R vibe-gateway:vibe-gateway /app 45 | 46 | # Switch to non-root user 47 | USER vibe-gateway 48 | 49 | # Expose all SMTP ports 50 | # 25 - Standard SMTP with STARTTLS 51 | # 587 - Message Submission with STARTTLS 52 | # 2525 - Alternative SMTP with STARTTLS 53 | # 465 - SMTP over SSL (implicit TLS) 54 | EXPOSE 25 587 2525 465 55 | 56 | # Set default environment variables 57 | ENV MAILPACE_API_TOKEN="" 58 | 59 | # Health check for the main service 60 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 61 | CMD timeout 5 bash -c ' 3 | # Install just: cargo install just 4 | 5 | # Justfile for Vibe Gateway 6 | # Run with: just 7 | # Install just: cargo install just 8 | 9 | # Default recipe 10 | default: test 11 | 12 | # Install dependencies 13 | install: 14 | cargo install cargo-nextest --locked 15 | cargo install cargo-tarpaulin 16 | cargo install cargo-watch 17 | 18 | # Build the project 19 | build: 20 | cargo build 21 | 22 | # Build for release 23 | build-release: 24 | cargo build --release 25 | 26 | # Run formatting check 27 | format: 28 | cargo fmt --all -- --check 29 | 30 | # Format code 31 | format-fix: 32 | cargo fmt --all 33 | 34 | # Run clippy 35 | clippy: 36 | cargo clippy --all-targets --all-features -- -D warnings 37 | 38 | # Run all tests 39 | test: format clippy unit integration performance 40 | echo "All tests completed successfully!" 41 | 42 | # Run unit tests 43 | unit: 44 | RUST_LOG=info cargo nextest run --profile unit 45 | 46 | # Run integration tests 47 | integration: 48 | RUST_LOG=info cargo nextest run --profile integration 49 | 50 | # Run performance tests 51 | performance: 52 | RUST_LOG=info cargo nextest run --profile performance --release 53 | 54 | # Run tests matching a pattern 55 | pattern PATTERN: 56 | RUST_LOG=info cargo nextest run -E 'test({{PATTERN}})' 57 | 58 | # Run tests with coverage 59 | coverage: 60 | cargo tarpaulin --engine llvm --out Html --output-dir coverage/ --skip-clean 61 | echo "Coverage report generated in coverage/tarpaulin-report.html" 62 | 63 | # Run tests in watch mode 64 | watch: 65 | cargo watch -x "nextest run" 66 | 67 | # Run security audit 68 | security: 69 | cargo audit 70 | 71 | # Show test results 72 | results: 73 | echo "Test Results:" 74 | test -f "nextest/default/junit.xml" && echo "✓ JUnit XML: nextest/default/junit.xml" || true 75 | test -f "nextest/default/run-summary.json" && echo "✓ Run Summary: nextest/default/run-summary.json" || true 76 | test -f "coverage/tarpaulin-report.html" && echo "✓ Coverage: coverage/tarpaulin-report.html" || true 77 | 78 | # Run tests with timing information 79 | timing: 80 | RUST_LOG=info cargo nextest run --profile default --final-status-level slow 81 | 82 | # Clean build artifacts 83 | clean: 84 | cargo clean 85 | rm -rf coverage/ 86 | rm -rf nextest/ 87 | rm -rf target/nextest/ 88 | 89 | # CI workflow - optimized for continuous integration 90 | ci: format clippy 91 | cargo nextest run --profile ci 92 | cargo tarpaulin --engine llvm --out xml --output-dir coverage/ 93 | 94 | # Development workflow - quick feedback 95 | dev: 96 | cargo nextest run --profile unit 97 | cargo nextest run --profile integration 98 | 99 | # Run specific test suite 100 | test-suite SUITE: 101 | cargo nextest run --profile {{SUITE}} 102 | 103 | # Generate documentation 104 | docs: 105 | cargo doc --open 106 | 107 | # Run benchmarks (if available) 108 | bench: 109 | cargo bench 110 | 111 | # Show help 112 | help: 113 | just --list 114 | -------------------------------------------------------------------------------- /src/mailpace.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use reqwest::Client; 3 | use serde::{Deserialize, Serialize}; 4 | use tracing::{debug, info}; 5 | 6 | #[derive(Serialize, Debug)] 7 | pub struct MailPacePayload { 8 | pub from: String, 9 | pub to: String, 10 | #[serde(skip_serializing_if = "Option::is_none")] 11 | pub cc: Option, 12 | #[serde(skip_serializing_if = "Option::is_none")] 13 | pub bcc: Option, 14 | #[serde(skip_serializing_if = "Option::is_none")] 15 | pub subject: Option, 16 | #[serde(skip_serializing_if = "Option::is_none")] 17 | pub htmlbody: Option, 18 | #[serde(skip_serializing_if = "Option::is_none")] 19 | pub textbody: Option, 20 | #[serde(skip_serializing_if = "Option::is_none")] 21 | pub replyto: Option, 22 | #[serde(skip_serializing_if = "Option::is_none")] 23 | pub list_unsubscribe: Option, 24 | #[serde(skip_serializing_if = "Option::is_none")] 25 | pub attachments: Option>, 26 | #[serde(skip_serializing_if = "Option::is_none")] 27 | pub tags: Option>, 28 | } 29 | 30 | #[derive(Serialize, Debug)] 31 | pub struct Attachment { 32 | pub name: String, 33 | pub content: String, 34 | pub content_type: String, 35 | #[serde(skip_serializing_if = "Option::is_none")] 36 | pub cid: Option, 37 | } 38 | 39 | #[derive(Deserialize)] 40 | pub struct MailPaceResponse { 41 | #[serde(default)] 42 | pub id: Option, 43 | #[serde(default)] 44 | #[allow(dead_code)] 45 | pub status: Option, 46 | #[serde(default)] 47 | #[allow(dead_code)] 48 | pub errors: Option>, 49 | } 50 | 51 | pub struct MailPaceClient { 52 | client: Client, 53 | endpoint: String, 54 | } 55 | 56 | impl MailPaceClient { 57 | pub fn new(client: Client, endpoint: String) -> Self { 58 | Self { client, endpoint } 59 | } 60 | 61 | pub async fn send_email(&self, payload: &MailPacePayload, token: &str) -> Result<()> { 62 | debug!("Sending payload to MailPace: {:?}", payload); 63 | 64 | let response = self 65 | .client 66 | .post(&self.endpoint) 67 | .header("Accept", "application/json") 68 | .header("Content-Type", "application/json") 69 | .header("MailPace-Server-Token", token) 70 | .json(payload) 71 | .send() 72 | .await 73 | .context("Failed to send request to MailPace API")?; 74 | 75 | if response.status().is_success() { 76 | let mailpace_response: MailPaceResponse = response 77 | .json() 78 | .await 79 | .context("Failed to parse MailPace response")?; 80 | info!("Email sent successfully, ID: {:?}", mailpace_response.id); 81 | Ok(()) 82 | } else { 83 | let status = response.status(); 84 | let error_text = response 85 | .text() 86 | .await 87 | .unwrap_or_else(|_| "Unknown error".to_string()); 88 | 89 | Err(anyhow::anyhow!( 90 | "MailPace API error ({}): {}", 91 | status, 92 | error_text 93 | )) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main, develop ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | name: Test Suite 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Install Rust 21 | uses: dtolnay/rust-toolchain@stable 22 | with: 23 | components: rustfmt, clippy 24 | 25 | - name: Cache cargo registry 26 | uses: actions/cache@v3 27 | with: 28 | path: | 29 | ~/.cargo/registry 30 | ~/.cargo/git 31 | ~/.cargo/bin 32 | target 33 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}-v2 34 | 35 | - name: Install cargo-nextest 36 | uses: taiki-e/install-action@v2 37 | with: 38 | tool: nextest 39 | 40 | - name: Install cargo-tarpaulin 41 | uses: taiki-e/install-action@v2 42 | with: 43 | tool: cargo-tarpaulin 44 | 45 | - name: Check formatting 46 | run: cargo fmt --all -- --check 47 | 48 | - name: Run clippy 49 | run: cargo clippy --all-targets --all-features -- -D warnings 50 | 51 | - name: Build project 52 | run: cargo build --release 53 | 54 | - name: Run unit tests 55 | run: cargo nextest run --profile unit 56 | 57 | - name: Run integration tests 58 | run: cargo nextest run --profile integration 59 | env: 60 | RUST_LOG: debug 61 | 62 | - name: Run performance tests 63 | run: cargo nextest run --profile performance --release 64 | 65 | - name: Run all tests with coverage 66 | run: cargo tarpaulin --config tarpaulin.toml 67 | continue-on-error: true 68 | 69 | - name: Upload coverage to Codecov 70 | uses: codecov/codecov-action@v3 71 | with: 72 | file: ./coverage/cobertura.xml 73 | fail_ci_if_error: false 74 | 75 | security: 76 | name: Security Audit 77 | runs-on: ubuntu-latest 78 | 79 | steps: 80 | - uses: actions/checkout@v4 81 | 82 | - name: Install Rust 83 | uses: dtolnay/rust-toolchain@stable 84 | 85 | - name: Cache cargo registry 86 | uses: actions/cache@v3 87 | with: 88 | path: | 89 | ~/.cargo/registry 90 | ~/.cargo/git 91 | target 92 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 93 | 94 | - name: Run cargo audit 95 | run: | 96 | cargo install cargo-audit 97 | cargo audit 98 | 99 | docker: 100 | name: Docker Build Test 101 | runs-on: ubuntu-latest 102 | needs: test 103 | 104 | steps: 105 | - uses: actions/checkout@v4 106 | 107 | - name: Set up Docker Buildx 108 | uses: docker/setup-buildx-action@v3 109 | 110 | - name: Build Docker image 111 | run: | 112 | docker build -t vibe-gateway:test . 113 | continue-on-error: true 114 | 115 | performance: 116 | name: Performance Tests 117 | runs-on: ubuntu-latest 118 | needs: test 119 | 120 | steps: 121 | - uses: actions/checkout@v4 122 | 123 | - name: Install Rust 124 | uses: dtolnay/rust-toolchain@stable 125 | 126 | - name: Cache cargo registry 127 | uses: actions/cache@v3 128 | with: 129 | path: | 130 | ~/.cargo/registry 131 | ~/.cargo/git 132 | target 133 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 134 | 135 | - name: Build release 136 | run: cargo build --release 137 | 138 | - name: Run performance tests 139 | run: cargo test --release --test performance_tests 140 | continue-on-error: true 141 | -------------------------------------------------------------------------------- /tests/common.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use lettre::transport::smtp::{authentication::Credentials, client::Tls, SmtpTransport}; 3 | use serde_json::json; 4 | use std::{net::SocketAddr, time::Duration}; 5 | use tokio::{ 6 | net::TcpListener, 7 | process::{Child, Command}, 8 | time::sleep, 9 | }; 10 | use wiremock::{ 11 | matchers::{header, method, path}, 12 | Mock, MockServer, ResponseTemplate, 13 | }; 14 | 15 | /// Mock MailPace API server for testing 16 | pub struct MockMailPaceServer { 17 | pub server: MockServer, 18 | } 19 | 20 | impl MockMailPaceServer { 21 | pub async fn new() -> Self { 22 | let server = MockServer::start().await; 23 | 24 | Self { server } 25 | } 26 | 27 | pub async fn setup_success_response(&self) -> &Self { 28 | Mock::given(method("POST")) 29 | .and(path("/api/v1/send")) 30 | .and(header("Content-Type", "application/json")) 31 | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 32 | "id": "test-message-id", 33 | "status": "sent" 34 | }))) 35 | .mount(&self.server) 36 | .await; 37 | 38 | self 39 | } 40 | 41 | #[allow(dead_code)] 42 | pub async fn setup_error_response(&self, status: u16, message: &str) -> &Self { 43 | Mock::given(method("POST")) 44 | .and(path("/api/v1/send")) 45 | .respond_with(ResponseTemplate::new(status).set_body_json(json!({ 46 | "errors": [message] 47 | }))) 48 | .mount(&self.server) 49 | .await; 50 | 51 | self 52 | } 53 | } 54 | 55 | /// Test server manager 56 | pub struct TestServer { 57 | pub child: Child, 58 | pub smtp_port: u16, 59 | pub mock_server: MockMailPaceServer, 60 | } 61 | 62 | impl TestServer { 63 | pub async fn new() -> Result { 64 | Self::new_with_config(&[]).await 65 | } 66 | 67 | pub async fn new_with_html_compression() -> Result { 68 | Self::new_with_config(&["--enable-html-compression"]).await 69 | } 70 | 71 | pub async fn new_with_config(extra_args: &[&str]) -> Result { 72 | let mock_server = MockMailPaceServer::new().await; 73 | 74 | // Find available port for SMTP 75 | let smtp_listener = TcpListener::bind("127.0.0.1:0").await?; 76 | let smtp_port = smtp_listener.local_addr()?.port(); 77 | drop(smtp_listener); 78 | 79 | // Create the formatted strings first to ensure they live long enough 80 | let listen_addr = format!("127.0.0.1:{smtp_port}"); 81 | let mailpace_endpoint = format!("{}/api/v1/send", mock_server.server.uri()); 82 | 83 | let mut base_args = vec![ 84 | "--listen", 85 | &listen_addr, 86 | "--mailpace-endpoint", 87 | &mailpace_endpoint, 88 | "--debug", 89 | ]; 90 | 91 | // Add extra configuration arguments 92 | for arg in extra_args { 93 | base_args.push(arg); 94 | } 95 | 96 | // Start the vibe-gateway server using pre-built binary to avoid cargo lock issues 97 | let child = Command::new("./target/release/vibe-gateway") 98 | .args(&base_args) 99 | .env("MAILPACE_API_TOKEN", "test-token") 100 | .spawn() 101 | .or_else(|_| { 102 | // Fallback to cargo run if binary doesn't exist 103 | let mut cargo_args = vec!["run", "--release", "--"]; 104 | cargo_args.extend(&base_args); 105 | 106 | Command::new("cargo") 107 | .args(&cargo_args) 108 | .env("MAILPACE_API_TOKEN", "test-token") 109 | .spawn() 110 | })?; 111 | 112 | let server = Self { 113 | child, 114 | smtp_port, 115 | mock_server, 116 | }; 117 | 118 | // Wait for server to start 119 | server.wait_for_server().await?; 120 | 121 | Ok(server) 122 | } 123 | 124 | async fn wait_for_server(&self) -> Result<()> { 125 | for _ in 0..30 { 126 | if (tokio::net::TcpStream::connect(format!("127.0.0.1:{}", self.smtp_port)).await) 127 | .is_ok() 128 | { 129 | return Ok(()); 130 | } 131 | sleep(Duration::from_millis(100)).await; 132 | } 133 | Err(anyhow::anyhow!("Server failed to start")) 134 | } 135 | 136 | pub fn smtp_address(&self) -> SocketAddr { 137 | format!("127.0.0.1:{}", self.smtp_port).parse().unwrap() 138 | } 139 | } 140 | 141 | impl Drop for TestServer { 142 | fn drop(&mut self) { 143 | // Kill the child process synchronously 144 | if let Err(e) = self.child.start_kill() { 145 | eprintln!("Failed to kill child process: {e}"); 146 | } 147 | // Give the process time to clean up 148 | std::thread::sleep(Duration::from_millis(100)); 149 | } 150 | } 151 | 152 | /// Helper function to create SMTP transport 153 | pub fn create_smtp_transport( 154 | server_addr: SocketAddr, 155 | credentials: Option, 156 | ) -> SmtpTransport { 157 | let mut builder = SmtpTransport::builder_dangerous(server_addr.ip().to_string()) 158 | .port(server_addr.port()) 159 | .tls(Tls::None); 160 | 161 | if let Some(creds) = credentials { 162 | builder = builder.credentials(creds); 163 | } 164 | 165 | builder.build() 166 | } 167 | -------------------------------------------------------------------------------- /docker-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Docker Startup Script for Vibe Gateway 4 | # This script provides an easy way to run the Vibe Gateway in different Docker configurations 5 | 6 | set -e 7 | 8 | # Colors for output 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | NC='\033[0m' # No Color 13 | 14 | # Function to print colored output 15 | print_status() { 16 | echo -e "${GREEN}[INFO]${NC} $1" 17 | } 18 | 19 | print_warning() { 20 | echo -e "${YELLOW}[WARN]${NC} $1" 21 | } 22 | 23 | print_error() { 24 | echo -e "${RED}[ERROR]${NC} $1" 25 | } 26 | 27 | # Function to show usage 28 | show_usage() { 29 | echo "Usage: $0 [MODE] [OPTIONS]" 30 | echo "" 31 | echo "MODES:" 32 | echo " multi-port Run with all SMTP ports (25, 587, 2525, 465) [DEFAULT]" 33 | echo " single-port Run with single port (2525)" 34 | echo " build Build the Docker image" 35 | echo " help Show this help message" 36 | echo "" 37 | echo "OPTIONS:" 38 | echo " --token TOKEN Set the MailPace API token" 39 | echo " --env-file Use custom .env file" 40 | echo " --dev Run in development mode with debug logging" 41 | echo "" 42 | echo "EXAMPLES:" 43 | echo " $0 multi-port --token your_api_token" 44 | echo " $0 single-port --dev" 45 | echo " $0 build" 46 | echo "" 47 | echo "SMTP PORT CONFIGURATIONS:" 48 | echo " Port 25 - Standard SMTP with STARTTLS support" 49 | echo " Port 587 - Message Submission with STARTTLS support" 50 | echo " Port 2525 - Alternative SMTP with STARTTLS support" 51 | echo " Port 465 - SMTP over SSL (implicit TLS, no STARTTLS)" 52 | } 53 | 54 | # Function to check if Docker is running 55 | check_docker() { 56 | if ! docker info > /dev/null 2>&1; then 57 | print_error "Docker is not running. Please start Docker and try again." 58 | exit 1 59 | fi 60 | } 61 | 62 | # Function to build the image 63 | build_image() { 64 | print_status "Building Vibe Gateway Docker image..." 65 | docker build -t vibe-gateway:latest . 66 | print_status "Image built successfully!" 67 | } 68 | 69 | # Function to run multi-port mode 70 | run_multi_port() { 71 | local token="$1" 72 | local env_file="$2" 73 | local debug="$3" 74 | 75 | print_status "Starting Vibe Gateway in multi-port mode..." 76 | print_status "Ports: 25 (SMTP+STARTTLS), 587 (Submission+STARTTLS), 2525 (Alt+STARTTLS), 465 (Implicit TLS)" 77 | 78 | local docker_args=() 79 | docker_args+=("-p" "25:25") 80 | docker_args+=("-p" "587:587") 81 | docker_args+=("-p" "2525:2525") 82 | docker_args+=("-p" "465:465") 83 | 84 | # Add TLS certificates for multi-port mode 85 | if [[ -f "test_cert.pem" && -f "test_key.pem" ]]; then 86 | print_status "Using test certificates for TLS" 87 | local cert_b64 88 | local key_b64 89 | cert_b64=$(base64 -i test_cert.pem) 90 | key_b64=$(base64 -i test_key.pem) 91 | docker_args+=("-e" "FULLCHAIN=$cert_b64") 92 | docker_args+=("-e" "PRIVATEKEY=$key_b64") 93 | else 94 | print_warning "Test certificates not found. TLS may not work properly." 95 | fi 96 | 97 | if [[ -n "$token" ]]; then 98 | docker_args+=("-e" "MAILPACE_API_TOKEN=$token") 99 | elif [[ -f "$env_file" ]]; then 100 | docker_args+=("--env-file" "$env_file") 101 | elif [[ -f ".env" ]]; then 102 | docker_args+=("--env-file" ".env") 103 | print_status "Using .env file for configuration" 104 | else 105 | print_warning "No API token provided. Users must authenticate with their own tokens via SMTP AUTH." 106 | fi 107 | 108 | local cmd_args=("--docker-multi-port") 109 | if [[ "$debug" == "true" ]]; then 110 | cmd_args+=("--debug") 111 | fi 112 | 113 | docker run --rm -it "${docker_args[@]}" vibe-gateway:latest ./vibe-gateway "${cmd_args[@]}" 114 | } 115 | 116 | # Function to run single-port mode 117 | run_single_port() { 118 | local token="$1" 119 | local env_file="$2" 120 | local debug="$3" 121 | 122 | print_status "Starting Vibe Gateway in single-port mode (port 2525)..." 123 | 124 | local docker_args=("-p" "2525:2525") 125 | 126 | if [[ -n "$token" ]]; then 127 | docker_args+=("-e" "MAILPACE_API_TOKEN=$token") 128 | elif [[ -f "$env_file" ]]; then 129 | docker_args+=("--env-file" "$env_file") 130 | elif [[ -f ".env" ]]; then 131 | docker_args+=("--env-file" ".env") 132 | print_status "Using .env file for configuration" 133 | else 134 | print_warning "No API token provided. Users must authenticate with their own tokens via SMTP AUTH." 135 | fi 136 | 137 | local cmd_args=("--listen" "0.0.0.0:2525" "--enable-tls") 138 | if [[ "$debug" == "true" ]]; then 139 | cmd_args+=("--debug") 140 | fi 141 | 142 | docker run --rm -it "${docker_args[@]}" vibe-gateway:latest ./vibe-gateway "${cmd_args[@]}" 143 | } 144 | 145 | # Parse command line arguments 146 | MODE="multi-port" 147 | TOKEN="" 148 | ENV_FILE="" 149 | DEBUG="false" 150 | 151 | while [[ $# -gt 0 ]]; do 152 | case $1 in 153 | multi-port|single-port|build|help) 154 | MODE="$1" 155 | shift 156 | ;; 157 | --token) 158 | TOKEN="$2" 159 | shift 2 160 | ;; 161 | --env-file) 162 | ENV_FILE="$2" 163 | shift 2 164 | ;; 165 | --dev) 166 | DEBUG="true" 167 | shift 168 | ;; 169 | *) 170 | print_error "Unknown option: $1" 171 | show_usage 172 | exit 1 173 | ;; 174 | esac 175 | done 176 | 177 | # Main execution 178 | check_docker 179 | 180 | case $MODE in 181 | help) 182 | show_usage 183 | ;; 184 | build) 185 | build_image 186 | ;; 187 | multi-port) 188 | build_image 189 | run_multi_port "$TOKEN" "$ENV_FILE" "$DEBUG" 190 | ;; 191 | single-port) 192 | build_image 193 | run_single_port "$TOKEN" "$ENV_FILE" "$DEBUG" 194 | ;; 195 | *) 196 | print_error "Invalid mode: $MODE" 197 | show_usage 198 | exit 1 199 | ;; 200 | esac 201 | -------------------------------------------------------------------------------- /test-docker-multiport.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Test script for Docker multi-port functionality 4 | # This script tests the Vibe Gateway Docker setup with all port configurations 5 | 6 | set -e 7 | 8 | # Colors for output 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | BLUE='\033[0;34m' 13 | NC='\033[0m' # No Color 14 | 15 | # Function to print colored output 16 | print_info() { 17 | echo -e "${BLUE}[INFO]${NC} $1" 18 | } 19 | 20 | print_success() { 21 | echo -e "${GREEN}[SUCCESS]${NC} $1" 22 | } 23 | 24 | print_warning() { 25 | echo -e "${YELLOW}[WARN]${NC} $1" 26 | } 27 | 28 | print_error() { 29 | echo -e "${RED}[ERROR]${NC} $1" 30 | } 31 | 32 | # Function to check if a command exists 33 | command_exists() { 34 | command -v "$1" >/dev/null 2>&1 35 | } 36 | 37 | # Function to check if a port is available 38 | port_available() { 39 | local port=$1 40 | ! nc -z localhost $port 2>/dev/null 41 | } 42 | 43 | # Function to wait for port to become available 44 | wait_for_port() { 45 | local port=$1 46 | local timeout=${2:-30} 47 | local count=0 48 | 49 | print_info "Waiting for port $port to become available..." 50 | while ! nc -z localhost $port 2>/dev/null; do 51 | if [ $count -ge $timeout ]; then 52 | print_error "Timeout waiting for port $port" 53 | return 1 54 | fi 55 | sleep 1 56 | count=$((count + 1)) 57 | done 58 | print_success "Port $port is available" 59 | } 60 | 61 | # Function to test SMTP connection 62 | test_smtp_connection() { 63 | local port=$1 64 | local description="$2" 65 | 66 | print_info "Testing SMTP connection on port $port ($description)..." 67 | 68 | # Simple telnet test 69 | if command_exists telnet; then 70 | { 71 | sleep 1 72 | echo "QUIT" 73 | sleep 1 74 | } | telnet localhost $port 2>/dev/null | grep -q "220.*vibe-gateway" 75 | 76 | if [ $? -eq 0 ]; then 77 | print_success "SMTP connection successful on port $port" 78 | return 0 79 | else 80 | print_error "SMTP connection failed on port $port" 81 | return 1 82 | fi 83 | else 84 | print_warning "telnet not available, skipping SMTP test for port $port" 85 | return 0 86 | fi 87 | } 88 | 89 | # Function to cleanup any running containers 90 | cleanup() { 91 | print_info "Cleaning up..." 92 | docker ps -q --filter "ancestor=vibe-gateway:latest" | xargs -r docker stop 93 | docker ps -a -q --filter "ancestor=vibe-gateway:latest" | xargs -r docker rm 94 | } 95 | 96 | # Main test function 97 | main() { 98 | print_info "Starting Vibe Gateway Docker Multi-Port Test" 99 | echo "==============================================" 100 | 101 | # Check prerequisites 102 | if ! command_exists docker; then 103 | print_error "Docker is not installed or not in PATH" 104 | exit 1 105 | fi 106 | 107 | if ! command_exists nc; then 108 | print_error "netcat (nc) is required for port testing" 109 | exit 1 110 | fi 111 | 112 | # Check if Docker is running 113 | if ! docker info >/dev/null 2>&1; then 114 | print_error "Docker is not running" 115 | exit 1 116 | fi 117 | 118 | # Cleanup any existing containers 119 | cleanup 120 | 121 | # Check if ports are available 122 | PORTS=(25 587 2525 465) 123 | for port in "${PORTS[@]}"; do 124 | if ! port_available $port; then 125 | print_error "Port $port is already in use" 126 | exit 1 127 | fi 128 | done 129 | 130 | # Build the Docker image 131 | print_info "Building Docker image..." 132 | if ! docker build -t vibe-gateway:latest . >/dev/null 2>&1; then 133 | print_error "Failed to build Docker image" 134 | exit 1 135 | fi 136 | print_success "Docker image built successfully" 137 | 138 | # Start the container 139 | print_info "Starting Vibe Gateway container in multi-port mode..." 140 | CONTAINER_ID=$(docker run -d \ 141 | -p 25:25 \ 142 | -p 587:587 \ 143 | -p 2525:2525 \ 144 | -p 465:465 \ 145 | -e MAILPACE_API_TOKEN=test_token \ 146 | vibe-gateway:latest ./vibe-gateway --docker-multi-port --debug) 147 | 148 | if [ $? -ne 0 ]; then 149 | print_error "Failed to start container" 150 | exit 1 151 | fi 152 | 153 | print_success "Container started with ID: ${CONTAINER_ID:0:12}" 154 | 155 | # Wait for services to start 156 | sleep 5 157 | 158 | # Test each port 159 | test_smtp_connection 25 "Standard SMTP with STARTTLS" 160 | test_smtp_connection 587 "Message Submission with STARTTLS" 161 | test_smtp_connection 2525 "Alternative SMTP with STARTTLS" 162 | test_smtp_connection 465 "SMTP over SSL (implicit TLS)" 163 | 164 | # Show container logs 165 | print_info "Container logs:" 166 | echo "---------------" 167 | docker logs $CONTAINER_ID --tail 20 168 | echo "---------------" 169 | 170 | # Test health check 171 | print_info "Testing health check..." 172 | sleep 10 # Wait for health check to run 173 | HEALTH_STATUS=$(docker inspect --format='{{.State.Health.Status}}' $CONTAINER_ID 2>/dev/null || echo "unknown") 174 | 175 | if [ "$HEALTH_STATUS" = "healthy" ]; then 176 | print_success "Health check passed" 177 | else 178 | print_warning "Health check status: $HEALTH_STATUS" 179 | fi 180 | 181 | # Cleanup 182 | print_info "Stopping and removing container..." 183 | docker stop $CONTAINER_ID >/dev/null 184 | docker rm $CONTAINER_ID >/dev/null 185 | 186 | print_success "Test completed successfully!" 187 | echo "" 188 | print_info "All SMTP ports are working correctly:" 189 | echo " Port 25 - Standard SMTP with STARTTLS" 190 | echo " Port 587 - Message Submission with STARTTLS" 191 | echo " Port 2525 - Alternative SMTP with STARTTLS" 192 | echo " Port 465 - SMTP over SSL (implicit TLS)" 193 | echo "" 194 | print_info "You can now run the server with:" 195 | echo " ./docker-run.sh multi-port --token your_api_token" 196 | echo " or" 197 | echo " docker-compose up -d" 198 | } 199 | 200 | # Handle script interruption 201 | trap cleanup EXIT INT TERM 202 | 203 | # Run main function 204 | main "$@" 205 | -------------------------------------------------------------------------------- /tests/mailpace_tests.rs: -------------------------------------------------------------------------------- 1 | use base64::{engine::general_purpose::STANDARD, Engine}; 2 | use serde_json::json; 3 | use vibe_gateway::mailpace::{Attachment, MailPaceClient, MailPacePayload}; 4 | use wiremock::{ 5 | matchers::{header, method, path}, 6 | Mock, MockServer, ResponseTemplate, 7 | }; 8 | 9 | #[tokio::test] 10 | async fn test_mailpace_client_success() { 11 | let mock_server = MockServer::start().await; 12 | 13 | Mock::given(method("POST")) 14 | .and(path("/api/v1/send")) 15 | .and(header("Content-Type", "application/json")) 16 | .and(header("MailPace-Server-Token", "test-token")) 17 | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 18 | "id": "msg_123", 19 | "status": "sent" 20 | }))) 21 | .mount(&mock_server) 22 | .await; 23 | 24 | let client = reqwest::Client::new(); 25 | let mailpace_client = MailPaceClient::new(client, format!("{}/api/v1/send", mock_server.uri())); 26 | 27 | let payload = MailPacePayload { 28 | from: "test@example.com".to_string(), 29 | to: "recipient@example.com".to_string(), 30 | cc: None, 31 | bcc: None, 32 | subject: Some("Test Subject".to_string()), 33 | htmlbody: Some("

Test

".to_string()), 34 | textbody: Some("Test".to_string()), 35 | replyto: None, 36 | list_unsubscribe: None, 37 | attachments: None, 38 | tags: Some(vec!["test".to_string()]), 39 | }; 40 | 41 | let result = mailpace_client.send_email(&payload, "test-token").await; 42 | assert!(result.is_ok()); 43 | } 44 | 45 | #[tokio::test] 46 | async fn test_mailpace_client_with_attachments() { 47 | let mock_server = MockServer::start().await; 48 | 49 | Mock::given(method("POST")) 50 | .and(path("/api/v1/send")) 51 | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 52 | "id": "msg_123", 53 | "status": "sent" 54 | }))) 55 | .mount(&mock_server) 56 | .await; 57 | 58 | let client = reqwest::Client::new(); 59 | let mailpace_client = MailPaceClient::new(client, format!("{}/api/v1/send", mock_server.uri())); 60 | 61 | let payload = MailPacePayload { 62 | from: "test@example.com".to_string(), 63 | to: "recipient@example.com".to_string(), 64 | cc: None, 65 | bcc: None, 66 | subject: Some("Test with Attachment".to_string()), 67 | htmlbody: None, 68 | textbody: Some("Test with attachment".to_string()), 69 | replyto: None, 70 | list_unsubscribe: None, 71 | attachments: Some(vec![Attachment { 72 | name: "test.txt".to_string(), 73 | content: STANDARD.encode("Test content"), 74 | content_type: "text/plain".to_string(), 75 | cid: None, 76 | }]), 77 | tags: None, 78 | }; 79 | 80 | let result = mailpace_client.send_email(&payload, "test-token").await; 81 | assert!(result.is_ok()); 82 | } 83 | 84 | #[tokio::test] 85 | async fn test_mailpace_client_error_response() { 86 | let mock_server = MockServer::start().await; 87 | 88 | Mock::given(method("POST")) 89 | .and(path("/api/v1/send")) 90 | .respond_with(ResponseTemplate::new(400).set_body_json(json!({ 91 | "errors": ["Invalid email format"] 92 | }))) 93 | .mount(&mock_server) 94 | .await; 95 | 96 | let client = reqwest::Client::new(); 97 | let mailpace_client = MailPaceClient::new(client, format!("{}/api/v1/send", mock_server.uri())); 98 | 99 | let payload = MailPacePayload { 100 | from: "invalid-email".to_string(), 101 | to: "recipient@example.com".to_string(), 102 | cc: None, 103 | bcc: None, 104 | subject: Some("Test Subject".to_string()), 105 | htmlbody: None, 106 | textbody: Some("Test".to_string()), 107 | replyto: None, 108 | list_unsubscribe: None, 109 | attachments: None, 110 | tags: None, 111 | }; 112 | 113 | let result = mailpace_client.send_email(&payload, "test-token").await; 114 | assert!(result.is_err()); 115 | 116 | let error_message = result.unwrap_err().to_string(); 117 | assert!(error_message.contains("400")); 118 | } 119 | 120 | #[tokio::test] 121 | async fn test_mailpace_client_network_error() { 122 | let client = reqwest::Client::new(); 123 | let mailpace_client = MailPaceClient::new( 124 | client, 125 | "http://non-existent-domain.invalid/api/v1/send".to_string(), 126 | ); 127 | 128 | let payload = MailPacePayload { 129 | from: "test@example.com".to_string(), 130 | to: "recipient@example.com".to_string(), 131 | cc: None, 132 | bcc: None, 133 | subject: Some("Test Subject".to_string()), 134 | htmlbody: None, 135 | textbody: Some("Test".to_string()), 136 | replyto: None, 137 | list_unsubscribe: None, 138 | attachments: None, 139 | tags: None, 140 | }; 141 | 142 | let result = mailpace_client.send_email(&payload, "test-token").await; 143 | assert!(result.is_err()); 144 | } 145 | 146 | #[test] 147 | fn test_attachment_serialization() { 148 | let attachment = Attachment { 149 | name: "test.txt".to_string(), 150 | content: "VGVzdCBjb250ZW50".to_string(), // "Test content" in base64 151 | content_type: "text/plain".to_string(), 152 | cid: Some("cid123".to_string()), 153 | }; 154 | 155 | let serialized = serde_json::to_string(&attachment).unwrap(); 156 | let expected = r#"{"name":"test.txt","content":"VGVzdCBjb250ZW50","content_type":"text/plain","cid":"cid123"}"#; 157 | assert_eq!(serialized, expected); 158 | } 159 | 160 | #[test] 161 | fn test_mailpace_payload_serialization() { 162 | let payload = MailPacePayload { 163 | from: "test@example.com".to_string(), 164 | to: "recipient@example.com".to_string(), 165 | cc: Some("cc@example.com".to_string()), 166 | bcc: None, 167 | subject: Some("Test Subject".to_string()), 168 | htmlbody: Some("

Test

".to_string()), 169 | textbody: Some("Test".to_string()), 170 | replyto: Some("reply@example.com".to_string()), 171 | list_unsubscribe: Some("".to_string()), 172 | attachments: None, 173 | tags: Some(vec!["test".to_string(), "unit".to_string()]), 174 | }; 175 | 176 | let serialized = serde_json::to_value(&payload).unwrap(); 177 | 178 | assert_eq!(serialized["from"], "test@example.com"); 179 | assert_eq!(serialized["to"], "recipient@example.com"); 180 | assert_eq!(serialized["cc"], "cc@example.com"); 181 | assert_eq!(serialized["subject"], "Test Subject"); 182 | assert_eq!(serialized["htmlbody"], "

Test

"); 183 | assert_eq!(serialized["textbody"], "Test"); 184 | assert_eq!(serialized["replyto"], "reply@example.com"); 185 | assert_eq!( 186 | serialized["list_unsubscribe"], 187 | "" 188 | ); 189 | assert_eq!(serialized["tags"], json!(["test", "unit"])); 190 | 191 | // bcc should not be present when None 192 | assert!(serialized.get("bcc").is_none()); 193 | } 194 | 195 | #[test] 196 | fn test_mailpace_payload_optional_fields() { 197 | let payload = MailPacePayload { 198 | from: "test@example.com".to_string(), 199 | to: "recipient@example.com".to_string(), 200 | cc: None, 201 | bcc: None, 202 | subject: None, 203 | htmlbody: None, 204 | textbody: None, 205 | replyto: None, 206 | list_unsubscribe: None, 207 | attachments: None, 208 | tags: None, 209 | }; 210 | 211 | let serialized = serde_json::to_value(&payload).unwrap(); 212 | 213 | assert_eq!(serialized["from"], "test@example.com"); 214 | assert_eq!(serialized["to"], "recipient@example.com"); 215 | 216 | // Optional fields should not be present 217 | assert!(serialized.get("cc").is_none()); 218 | assert!(serialized.get("bcc").is_none()); 219 | assert!(serialized.get("subject").is_none()); 220 | assert!(serialized.get("htmlbody").is_none()); 221 | assert!(serialized.get("textbody").is_none()); 222 | assert!(serialized.get("replyto").is_none()); 223 | assert!(serialized.get("list_unsubscribe").is_none()); 224 | assert!(serialized.get("attachments").is_none()); 225 | assert!(serialized.get("tags").is_none()); 226 | } 227 | -------------------------------------------------------------------------------- /src/compression.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use minify_html::{minify, Cfg}; 3 | use std::panic; 4 | use tracing::{debug, warn}; 5 | 6 | pub struct HtmlCompressor { 7 | config: Cfg, 8 | } 9 | 10 | impl HtmlCompressor { 11 | pub fn new() -> Self { 12 | let config = Cfg { 13 | do_not_minify_doctype: false, 14 | ensure_spec_compliant_unquoted_attribute_values: true, 15 | keep_closing_tags: true, // Keep for email client compatibility 16 | keep_html_and_head_opening_tags: true, // Keep for email client compatibility 17 | keep_spaces_between_attributes: false, 18 | keep_comments: false, // Remove comments 19 | minify_css: true, 20 | minify_js: true, 21 | remove_bangs: false, 22 | remove_processing_instructions: true, 23 | ..Cfg::default() 24 | }; 25 | 26 | Self { config } 27 | } 28 | 29 | /// Compress HTML content if it appears to be valid HTML 30 | pub fn compress_html(&self, html_content: &str) -> Result { 31 | // Quick check if content looks like HTML 32 | if !self.is_html_content(html_content) { 33 | debug!("Content doesn't appear to be HTML, skipping compression"); 34 | return Ok(html_content.to_string()); 35 | } 36 | 37 | debug!( 38 | "Compressing HTML content (original size: {} bytes)", 39 | html_content.len() 40 | ); 41 | 42 | let original_bytes = html_content.as_bytes(); 43 | 44 | match panic::catch_unwind(|| minify(original_bytes, &self.config)) { 45 | Ok(compressed_bytes) => { 46 | let compressed = String::from_utf8_lossy(&compressed_bytes).to_string(); 47 | let original_size = html_content.len(); 48 | let compressed_size = compressed.len(); 49 | let compression_ratio = if original_size > 0 { 50 | ((original_size - compressed_size) as f64 / original_size as f64) * 100.0 51 | } else { 52 | 0.0 53 | }; 54 | 55 | debug!( 56 | "HTML compression successful: {} -> {} bytes ({:.1}% reduction)", 57 | original_size, compressed_size, compression_ratio 58 | ); 59 | 60 | Ok(compressed) 61 | } 62 | Err(_) => { 63 | warn!("HTML compression failed (panic caught), using original content"); 64 | // Return original content if compression fails 65 | Ok(html_content.to_string()) 66 | } 67 | } 68 | } 69 | 70 | /// Simple heuristic to detect if content is HTML 71 | fn is_html_content(&self, content: &str) -> bool { 72 | let content_lower = content.trim().to_lowercase(); 73 | 74 | // Check for common HTML indicators 75 | content_lower.contains("') 85 | && (content_lower.contains(" Self { 98 | Self::new() 99 | } 100 | } 101 | 102 | #[cfg(test)] 103 | mod tests { 104 | use super::*; 105 | 106 | #[test] 107 | fn test_html_compression_basic() { 108 | let compressor = HtmlCompressor::new(); 109 | let html = r#" 110 | 111 | 112 | Test Email 113 | 114 | 115 |

Hello World

116 |

This is a test email with some HTML content.

117 | 118 | 119 | "#; 120 | 121 | let result = compressor.compress_html(html).unwrap(); 122 | 123 | // Compressed version should be smaller 124 | assert!(result.len() < html.len()); 125 | 126 | // Should still contain the essential content 127 | assert!(result.contains("Hello World")); 128 | assert!(result.contains("This is a test email")); 129 | } 130 | 131 | #[test] 132 | fn test_html_compression_with_comments() { 133 | let compressor = HtmlCompressor::new(); 134 | let html = r#" 135 | 136 | 137 | 138 |

Content

139 | 140 | 141 | 142 | "#; 143 | 144 | let result = compressor.compress_html(html).unwrap(); 145 | 146 | // Comments should be removed 147 | assert!(!result.contains("This is a comment")); 148 | assert!(!result.contains("Another comment")); 149 | 150 | // Content should remain 151 | assert!(result.contains("Content")); 152 | } 153 | 154 | #[test] 155 | fn test_html_compression_with_whitespace() { 156 | let compressor = HtmlCompressor::new(); 157 | let html = r#" 158 | 159 | 160 |

Spaced content

161 |
162 | 163 | Text 164 | 165 |
166 | 167 | 168 | "#; 169 | 170 | let result = compressor.compress_html(html).unwrap(); 171 | 172 | // Should be significantly smaller due to whitespace removal 173 | assert!(result.len() < html.len()); 174 | 175 | // Content should still be present 176 | assert!(result.contains("Spaced")); 177 | assert!(result.contains("content")); 178 | assert!(result.contains("Text")); 179 | } 180 | 181 | #[test] 182 | fn test_is_html_content_positive() { 183 | let compressor = HtmlCompressor::new(); 184 | 185 | assert!(compressor.is_html_content("Content")); 186 | assert!(compressor.is_html_content("")); 187 | assert!(compressor.is_html_content("
Content
")); 188 | assert!(compressor.is_html_content("

Simple paragraph

")); 189 | assert!(compressor.is_html_content("Text with
tags")); 190 | assert!(compressor.is_html_content("Link: click")); 191 | } 192 | 193 | #[test] 194 | fn test_is_html_content_negative() { 195 | let compressor = HtmlCompressor::new(); 196 | 197 | assert!(!compressor.is_html_content("Plain text content")); 198 | assert!(!compressor.is_html_content("Email with some words")); 199 | assert!(!compressor.is_html_content("Numbers and symbols: 123 @ # $")); 200 | assert!(!compressor.is_html_content("")); 201 | } 202 | 203 | #[test] 204 | fn test_non_html_content_passthrough() { 205 | let compressor = HtmlCompressor::new(); 206 | let plain_text = "This is just plain text without any HTML tags."; 207 | 208 | let result = compressor.compress_html(plain_text).unwrap(); 209 | 210 | // Non-HTML content should pass through unchanged 211 | assert_eq!(result, plain_text); 212 | } 213 | 214 | #[test] 215 | fn test_malformed_html_fallback() { 216 | let compressor = HtmlCompressor::new(); 217 | let malformed_html = "

Unclosed paragraph

Content"; 218 | 219 | let result = compressor.compress_html(malformed_html); 220 | 221 | // Should not panic and should return some result 222 | assert!(result.is_ok()); 223 | } 224 | 225 | #[test] 226 | fn test_empty_html() { 227 | let compressor = HtmlCompressor::new(); 228 | let empty_html = ""; 229 | 230 | let result = compressor.compress_html(empty_html).unwrap(); 231 | 232 | // Empty content should remain empty 233 | assert_eq!(result, ""); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /TESTING.md: -------------------------------------------------------------------------------- 1 | # Test Suite Documentation 2 | 3 | This document describes the comprehensive testing suite for the Vibe Gateway SMTP server using cargo-nextest. 4 | 5 | ## Test Infrastructure 6 | 7 | ### cargo-nextest 8 | This project uses [cargo-nextest](https://nexte.st/) for test execution, which provides: 9 | - **Faster execution**: Parallel test execution with intelligent scheduling 10 | - **Better isolation**: Tests run in separate processes with proper cleanup 11 | - **Rich output**: Detailed timing, retry information, and multiple output formats 12 | - **Configurable profiles**: Different test configurations for various scenarios 13 | - **Reliable results**: Built-in retry mechanisms and flaky test detection 14 | 15 | ### Test Profiles 16 | The project includes several nextest profiles configured in `.config/nextest.toml`: 17 | - **default**: Standard test execution with retries and reasonable timeouts 18 | - **ci**: Optimized for continuous integration with increased timeouts 19 | - **integration**: Specific settings for integration tests with reduced parallelism 20 | - **performance**: Sequential execution for performance tests 21 | - **unit**: Fast execution for unit tests only 22 | 23 | ## Test Structure 24 | 25 | ### Integration Tests (`tests/integration_tests.rs`) 26 | - **Purpose**: End-to-end testing of the entire SMTP server functionality 27 | - **Coverage**: Tests the complete workflow from SMTP client to MailPace API 28 | - **Test Cases**: 29 | - Basic email sending 30 | - HTML email content 31 | - Email attachments 32 | - MailPace-specific headers 33 | - Authentication scenarios 34 | - Multiple recipients 35 | - Large email content 36 | - SMTP command handling 37 | - Default token usage 38 | 39 | ### Unit Tests (`tests/mailpace_tests.rs`) 40 | - **Purpose**: Testing individual components in isolation 41 | - **Coverage**: MailPace client functionality, payload serialization 42 | - **Test Cases**: 43 | - MailPace API success responses 44 | - MailPace API error handling 45 | - Attachment handling 46 | - Payload serialization 47 | - Network error scenarios 48 | 49 | ### Performance Tests (`tests/performance_tests.rs`) 50 | - **Purpose**: Performance and load testing 51 | - **Coverage**: Throughput, concurrency, resource usage 52 | - **Test Cases**: 53 | - Concurrent email sending 54 | - Throughput measurement 55 | - Large email performance 56 | - Connection handling under load 57 | - Memory usage with attachments 58 | - Stress testing 59 | 60 | ## Running Tests 61 | 62 | ### Prerequisites 63 | ```bash 64 | # Install Rust and Cargo 65 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh 66 | 67 | # Install cargo-nextest 68 | cargo install cargo-nextest --locked 69 | 70 | # Build the project 71 | cargo build 72 | ``` 73 | 74 | ### Using the Test Runner Script 75 | ```bash 76 | # Run all tests with the test runner 77 | ./test.sh 78 | 79 | # Run specific test suites 80 | ./test.sh unit # Unit tests only 81 | ./test.sh integration # Integration tests only 82 | ./test.sh performance # Performance tests only 83 | 84 | # Run tests with pattern matching 85 | ./test.sh pattern "smtp" # Run tests matching "smtp" 86 | 87 | # Run tests in watch mode 88 | ./test.sh watch 89 | 90 | # Generate coverage report 91 | ./test.sh coverage 92 | 93 | # Show detailed timing information 94 | ./test.sh timing 95 | ``` 96 | 97 | ### Running Tests with cargo-nextest Directly 98 | ```bash 99 | # Run all tests with nextest 100 | cargo nextest run 101 | 102 | # Run tests with specific profile 103 | cargo nextest run --profile ci 104 | cargo nextest run --profile integration 105 | cargo nextest run --profile performance 106 | 107 | # Run tests with output 108 | cargo nextest run --success-output immediate 109 | 110 | # Run tests with debug logging 111 | RUST_LOG=debug cargo nextest run 112 | ``` 113 | 114 | ### Running Specific Test Suites 115 | ```bash 116 | # Integration tests only 117 | cargo nextest run --profile integration 118 | 119 | # Unit tests only 120 | cargo nextest run --profile unit 121 | 122 | # Performance tests only 123 | cargo nextest run --profile performance --release 124 | 125 | # Tests matching a pattern 126 | cargo nextest run -E 'test(smtp)' 127 | cargo nextest run -E 'test(mailpace)' 128 | ``` 129 | 130 | ### Advanced nextest Features 131 | ```bash 132 | # Run tests with retries 133 | cargo nextest run --retries 3 134 | 135 | # Run with timing information 136 | cargo nextest run --final-status-level slow 137 | 138 | # Generate JUnit XML output 139 | cargo nextest run --profile ci --output-format junit > test-results.xml 140 | 141 | # Run tests with specific timeout 142 | cargo nextest run --profile default --slow-timeout 120s 143 | ``` 144 | 145 | ## Test Environment Setup 146 | 147 | ### Mock Server 148 | - Uses `wiremock` to simulate the MailPace API 149 | - Automatically starts and stops with each test 150 | - Configurable responses for different scenarios 151 | 152 | ### Test Server 153 | - Starts a real instance of the vibe-gateway server 154 | - Uses random ports to avoid conflicts 155 | - Automatically cleaned up after tests 156 | 157 | ### Test Data 158 | - Uses realistic email content and headers 159 | - Tests various payload sizes and formats 160 | - Includes edge cases and error conditions 161 | 162 | ## Continuous Integration 163 | 164 | ### GitHub Actions Workflow 165 | Located at `.github/workflows/ci.yml`, the CI pipeline includes: 166 | 167 | 1. **Code Quality Checks**: 168 | - Rust formatting (`cargo fmt`) 169 | - Clippy linting (`cargo clippy`) 170 | 171 | 2. **Testing**: 172 | - Unit tests (`cargo test --lib`) 173 | - Integration tests (`cargo test --test integration_tests`) 174 | - Performance tests (`cargo test --test performance_tests`) 175 | 176 | 3. **Security**: 177 | - Dependency audit (`cargo audit`) 178 | 179 | 4. **Build Verification**: 180 | - Release build (`cargo build --release`) 181 | - Docker image build 182 | 183 | ### Test Coverage 184 | - Coverage reporting with `cargo tarpaulin` 185 | - Uploaded to Codecov for tracking 186 | - Fails CI if coverage drops significantly 187 | 188 | ## Test Configuration 189 | 190 | ### Environment Variables 191 | - `RUST_LOG`: Set logging level for tests 192 | - `MAILPACE_API_TOKEN`: Default API token for testing 193 | 194 | ### Test Timeouts 195 | - Individual test timeout: 30 seconds 196 | - Server startup timeout: 3 seconds 197 | - Network operation timeout: 10 seconds 198 | 199 | ## Writing New Tests 200 | 201 | ### Integration Test Template 202 | ```rust 203 | #[tokio::test] 204 | async fn test_new_feature() -> Result<()> { 205 | let server = TestServer::new().await?; 206 | server.mock_server.setup_success_response().await; 207 | 208 | let transport = create_smtp_transport( 209 | server.smtp_address(), 210 | Some(Credentials::new("test-token".to_string(), "test-token".to_string())) 211 | ); 212 | 213 | // Test implementation 214 | 215 | Ok(()) 216 | } 217 | ``` 218 | 219 | ### Unit Test Template 220 | ```rust 221 | #[tokio::test] 222 | async fn test_component_function() { 223 | // Test setup 224 | 225 | // Test execution 226 | 227 | // Assertions 228 | assert!(result.is_ok()); 229 | } 230 | ``` 231 | 232 | ## Test Data Management 233 | 234 | ### Mock Responses 235 | - Realistic MailPace API responses 236 | - Error scenarios with proper status codes 237 | - Edge cases and malformed data 238 | 239 | ### Test Fixtures 240 | - Sample email content 241 | - Various attachment types 242 | - Different header configurations 243 | 244 | ## Debugging Tests 245 | 246 | ### Logging 247 | ```bash 248 | # Enable debug logging 249 | RUST_LOG=debug cargo test test_name -- --nocapture 250 | 251 | # Enable trace logging for detailed output 252 | RUST_LOG=trace cargo test test_name -- --nocapture 253 | ``` 254 | 255 | ### Test Isolation 256 | - Each test runs in isolation 257 | - Clean state between tests 258 | - No shared global state 259 | 260 | ### Common Issues 261 | 1. **Port conflicts**: Tests use random ports 262 | 2. **Timing issues**: Tests include proper wait mechanisms 263 | 3. **Resource cleanup**: Automatic cleanup via Drop trait 264 | 265 | ## Performance Benchmarks 266 | 267 | ### Throughput Targets 268 | - Minimum 5 emails/second sequential 269 | - Support for 20+ concurrent connections 270 | - Handle 1MB emails within 10 seconds 271 | 272 | ### Memory Usage 273 | - Efficient handling of large attachments 274 | - Proper cleanup of resources 275 | - No memory leaks under load 276 | 277 | ## Contributing 278 | 279 | ### Adding Tests 280 | 1. Identify the feature or bug to test 281 | 2. Choose appropriate test type (integration/unit/performance) 282 | 3. Follow existing patterns and conventions 283 | 4. Include both positive and negative test cases 284 | 5. Add appropriate documentation 285 | 286 | ### Test Review Checklist 287 | - [ ] Tests are deterministic and repeatable 288 | - [ ] Proper error handling and cleanup 289 | - [ ] Realistic test data and scenarios 290 | - [ ] Performance implications considered 291 | - [ ] Documentation updated 292 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Test runner script for Vibe Gateway using cargo-nextest 4 | # This script provides convenient commands for running different test suites with nextest 5 | 6 | set -e 7 | 8 | # Colors for output 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | BLUE='\033[0;34m' 13 | NC='\033[0m' # No Color 14 | 15 | print_header() { 16 | echo -e "${BLUE}=== $1 ===${NC}" 17 | } 18 | 19 | print_warning() { 20 | echo -e "${YELLOW}WARNING: $1${NC}" 21 | } 22 | 23 | print_error() { 24 | echo -e "${RED}ERROR: $1${NC}" 25 | } 26 | 27 | print_success() { 28 | echo -e "${GREEN}SUCCESS: $1${NC}" 29 | } 30 | 31 | # Function to run tests with proper error handling 32 | run_test() { 33 | local test_name=$1 34 | local test_command=$2 35 | 36 | print_header "Running $test_name" 37 | 38 | if eval "$test_command"; then 39 | print_success "$test_name completed successfully" 40 | return 0 41 | else 42 | print_error "$test_name failed" 43 | return 1 44 | fi 45 | } 46 | 47 | # Function to check if cargo and cargo-nextest are available 48 | check_dependencies() { 49 | if ! command -v cargo &> /dev/null; then 50 | print_error "Cargo is not installed. Please install Rust and Cargo first." 51 | exit 1 52 | fi 53 | 54 | if ! command -v cargo-nextest &> /dev/null; then 55 | print_warning "cargo-nextest is not installed. Installing..." 56 | cargo install cargo-nextest --locked 57 | fi 58 | } 59 | 60 | # Function to install cargo-nextest if not present 61 | install_nextest() { 62 | if ! command -v cargo-nextest &> /dev/null; then 63 | print_header "Installing cargo-nextest" 64 | cargo install cargo-nextest --locked 65 | print_success "cargo-nextest installed successfully" 66 | else 67 | print_success "cargo-nextest is already installed" 68 | fi 69 | } 70 | 71 | # Function to build the project 72 | build_project() { 73 | print_header "Building project" 74 | cargo build --release 75 | } 76 | 77 | # Function to run unit tests 78 | run_unit_tests() { 79 | run_test "Unit Tests" "RUST_LOG=info cargo nextest run --profile unit" 80 | } 81 | 82 | # Function to run integration tests 83 | run_integration_tests() { 84 | # Build the project first to avoid cargo lock issues during test 85 | print_header "Building project before integration tests" 86 | cargo build --release 87 | 88 | run_test "Integration Tests" "RUST_LOG=info cargo nextest run --profile integration --jobs 1" 89 | } 90 | 91 | # Function to run performance tests 92 | run_performance_tests() { 93 | run_test "Performance Tests" "RUST_LOG=info cargo nextest run --profile performance --release" 94 | } 95 | 96 | # Function to run all tests with nextest 97 | run_all_tests_nextest() { 98 | local failed=0 99 | 100 | run_unit_tests || failed=1 101 | run_integration_tests || failed=1 102 | run_performance_tests || failed=1 103 | 104 | if [ $failed -eq 0 ]; then 105 | print_success "All tests passed!" 106 | else 107 | print_error "Some tests failed. Check the output above." 108 | exit 1 109 | fi 110 | } 111 | 112 | # Function to run specific test patterns 113 | run_test_pattern() { 114 | local pattern=$1 115 | if [ -z "$pattern" ]; then 116 | print_error "Test pattern is required" 117 | exit 1 118 | fi 119 | 120 | run_test "Tests matching pattern '$pattern'" "RUST_LOG=info cargo nextest run '$pattern'" 121 | } 122 | 123 | # Function to run tests with timing information 124 | run_tests_with_timing() { 125 | run_test "Tests with timing" "RUST_LOG=info cargo nextest run --profile default --final-status-level slow" 126 | } 127 | 128 | # Function to run all tests 129 | run_all_tests() { 130 | run_all_tests_nextest 131 | } 132 | 133 | # Function to run tests with coverage using nextest 134 | run_coverage() { 135 | print_header "Running tests with coverage using nextest" 136 | 137 | if ! command -v cargo-tarpaulin &> /dev/null; then 138 | print_warning "cargo-tarpaulin not found. Installing..." 139 | cargo install cargo-tarpaulin 140 | fi 141 | 142 | # Use tarpaulin with nextest for better coverage 143 | cargo tarpaulin --engine llvm --out Html --output-dir coverage/ --skip-clean 144 | print_success "Coverage report generated in coverage/tarpaulin-report.html" 145 | } 146 | 147 | # Function to run tests in watch mode 148 | run_watch() { 149 | print_header "Running tests in watch mode" 150 | 151 | if ! command -v cargo-watch &> /dev/null; then 152 | print_warning "cargo-watch not found. Installing..." 153 | cargo install cargo-watch 154 | fi 155 | 156 | cargo watch -x "nextest run" 157 | } 158 | 159 | # Function to show test results in different formats 160 | show_test_results() { 161 | print_header "Showing detailed test results" 162 | 163 | if [ -f "nextest/default/junit.xml" ]; then 164 | print_success "JUnit XML report available at: nextest/default/junit.xml" 165 | fi 166 | 167 | if [ -f "nextest/default/binaries-list.json" ]; then 168 | print_success "Binary list available at: nextest/default/binaries-list.json" 169 | fi 170 | 171 | # Show recent test run summary 172 | if [ -f "nextest/default/run-summary.json" ]; then 173 | print_success "Run summary available at: nextest/default/run-summary.json" 174 | fi 175 | } 176 | 177 | # Function to run security audit 178 | run_security_audit() { 179 | print_header "Running security audit" 180 | 181 | if ! command -v cargo-audit &> /dev/null; then 182 | print_warning "cargo-audit not found. Installing..." 183 | cargo install cargo-audit 184 | fi 185 | 186 | cargo audit 187 | } 188 | 189 | # Function to clean up test artifacts 190 | cleanup() { 191 | print_header "Cleaning up" 192 | cargo clean 193 | rm -rf coverage/ 194 | rm -rf nextest/ 195 | rm -rf target/nextest/ 196 | print_success "Cleanup completed" 197 | } 198 | 199 | # Function to show help 200 | show_help() { 201 | cat << EOF 202 | Vibe Gateway Test Runner (using cargo-nextest) 203 | 204 | Usage: $0 [COMMAND] [OPTIONS] 205 | 206 | Commands: 207 | all Run all tests and checks (default) 208 | unit Run unit tests only 209 | integration Run integration tests only 210 | performance Run performance tests only 211 | coverage Run tests with coverage report 212 | security Run security audit 213 | build Build the project 214 | clean Clean up build artifacts 215 | watch Run tests in watch mode 216 | pattern Run tests matching pattern 217 | timing Run tests with detailed timing information 218 | results Show test results and reports 219 | install Install cargo-nextest 220 | help Show this help message 221 | 222 | Nextest Features: 223 | - Faster test execution with intelligent parallelization 224 | - Better test isolation and retry mechanisms 225 | - Detailed timing and performance metrics 226 | - Multiple output formats (JSON, JUnit XML) 227 | - Configurable test profiles for different scenarios 228 | 229 | Examples: 230 | $0 # Run all tests 231 | $0 integration # Run only integration tests 232 | $0 pattern "smtp" # Run tests matching "smtp" 233 | $0 coverage # Generate coverage report 234 | $0 watch # Run tests in watch mode 235 | $0 timing # Show detailed timing information 236 | 237 | Environment Variables: 238 | RUST_LOG Set logging level (error, warn, info, debug, trace) 239 | NEXTEST_PROFILE Override the nextest profile to use 240 | 241 | Configuration: 242 | Nextest configuration is in .config/nextest.toml 243 | Test profiles: default, ci, integration, performance, unit 244 | 245 | For more detailed information, see TESTING.md 246 | EOF 247 | } 248 | 249 | # Main script logic 250 | main() { 251 | check_dependencies 252 | 253 | case "${1:-all}" in 254 | "all") 255 | run_all_tests 256 | ;; 257 | "unit") 258 | run_unit_tests 259 | ;; 260 | "integration") 261 | run_integration_tests 262 | ;; 263 | "performance") 264 | run_performance_tests 265 | ;; 266 | "coverage") 267 | run_coverage 268 | ;; 269 | "security") 270 | run_security_audit 271 | ;; 272 | "build") 273 | build_project 274 | ;; 275 | "clean") 276 | cleanup 277 | ;; 278 | "watch") 279 | run_watch 280 | ;; 281 | "pattern") 282 | run_test_pattern "$2" 283 | ;; 284 | "timing") 285 | run_tests_with_timing 286 | ;; 287 | "results") 288 | show_test_results 289 | ;; 290 | "install") 291 | install_nextest 292 | ;; 293 | "help"|"-h"|"--help") 294 | show_help 295 | ;; 296 | *) 297 | print_error "Unknown command: $1" 298 | show_help 299 | exit 1 300 | ;; 301 | esac 302 | } 303 | 304 | # Run main function with all arguments 305 | main "$@" 306 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use clap::Parser; 3 | use reqwest::Client; 4 | use tokio::net::TcpListener; 5 | use tracing::{error, info}; 6 | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; 7 | 8 | mod cli; 9 | mod compression; 10 | mod connection; 11 | mod mailpace; 12 | mod mime; 13 | mod smtp; 14 | mod tls; 15 | 16 | use cli::Args; 17 | use mailpace::MailPaceClient; 18 | use smtp::SmtpSession; 19 | 20 | #[derive(Clone)] 21 | struct ServerConfig { 22 | client: Client, 23 | mailpace_endpoint: String, 24 | default_mailpace_token: Option, 25 | enable_attachments: bool, 26 | max_attachment_size: usize, 27 | max_attachments: usize, 28 | enable_html_compression: bool, 29 | } 30 | 31 | #[derive(Clone, Copy)] 32 | enum TlsMode { 33 | None, 34 | Starttls, 35 | Implicit, 36 | } 37 | 38 | async fn start_listener( 39 | address: String, 40 | tls_mode: TlsMode, 41 | config: ServerConfig, 42 | tls_acceptor: Option, 43 | ) -> Result<()> { 44 | let listener = TcpListener::bind(&address) 45 | .await 46 | .context(format!("Failed to bind to {address}"))?; 47 | 48 | let tls_mode_str = match tls_mode { 49 | TlsMode::None => "Plain", 50 | TlsMode::Starttls => "STARTTLS", 51 | TlsMode::Implicit => "Implicit TLS", 52 | }; 53 | 54 | info!("SMTP server listening on {} ({})", address, tls_mode_str); 55 | 56 | loop { 57 | let (stream, addr) = listener.accept().await?; 58 | info!("New connection from {} on {}", addr, address); 59 | 60 | let config = config.clone(); 61 | let tls_acceptor = tls_acceptor.clone(); 62 | let address_clone = address.clone(); 63 | 64 | tokio::spawn(async move { 65 | let result = match tls_mode { 66 | TlsMode::Implicit => { 67 | // For implicit TLS (port 465), immediately upgrade to TLS 68 | if let Some(acceptor) = tls_acceptor { 69 | match acceptor.accept(stream).await { 70 | Ok(tls_stream) => { 71 | let mailpace_client = 72 | MailPaceClient::new(config.client, config.mailpace_endpoint); 73 | let mut session = SmtpSession::new( 74 | mailpace_client, 75 | config.default_mailpace_token, 76 | None, // No STARTTLS for implicit TLS 77 | config.enable_attachments, 78 | config.max_attachment_size, 79 | config.max_attachments, 80 | config.enable_html_compression, 81 | ); 82 | session.handle_tls_stream(Box::new(tls_stream)).await 83 | } 84 | Err(e) => { 85 | error!( 86 | "Failed to establish implicit TLS connection for {}: {}", 87 | addr, e 88 | ); 89 | return; 90 | } 91 | } 92 | } else { 93 | error!("Implicit TLS requested but no TLS acceptor configured"); 94 | return; 95 | } 96 | } 97 | _ => { 98 | // For plain and STARTTLS modes, start with plain connection 99 | let session_tls_acceptor = match tls_mode { 100 | TlsMode::Starttls => tls_acceptor, 101 | _ => None, 102 | }; 103 | 104 | let mailpace_client = 105 | MailPaceClient::new(config.client, config.mailpace_endpoint); 106 | let mut session = SmtpSession::new( 107 | mailpace_client, 108 | config.default_mailpace_token, 109 | session_tls_acceptor, 110 | config.enable_attachments, 111 | config.max_attachment_size, 112 | config.max_attachments, 113 | config.enable_html_compression, 114 | ); 115 | session.handle(stream).await 116 | } 117 | }; 118 | 119 | if let Err(e) = result { 120 | error!("Session error for {} on {}: {}", addr, address_clone, e); 121 | } 122 | info!("Connection closed for {} on {}", addr, address_clone); 123 | }); 124 | } 125 | } 126 | 127 | #[tokio::main] 128 | async fn main() -> Result<()> { 129 | let args = Args::parse(); 130 | 131 | // Initialize logging 132 | let filter = if args.debug { "debug" } else { "info" }; 133 | 134 | tracing_subscriber::registry() 135 | .with(tracing_subscriber::fmt::layer()) 136 | .with(tracing_subscriber::EnvFilter::new(filter)) 137 | .init(); 138 | 139 | if args.default_mailpace_token.is_none() { 140 | info!("No default MailPace API token provided. Users must authenticate with their API token via SMTP AUTH."); 141 | } else { 142 | info!( 143 | "Default MailPace API token loaded from environment. Users can override via SMTP AUTH." 144 | ); 145 | } 146 | 147 | // Log attachment configuration 148 | if args.enable_attachments { 149 | info!( 150 | "Attachment support enabled: max {} attachments, max size {} bytes each", 151 | args.max_attachments, args.max_attachment_size 152 | ); 153 | } else { 154 | info!("Attachment support disabled"); 155 | } 156 | 157 | // Log HTML compression configuration 158 | if args.enable_html_compression { 159 | info!("HTML compression enabled for email bodies"); 160 | } else { 161 | info!("HTML compression disabled"); 162 | } 163 | 164 | // Load TLS configuration if needed 165 | let tls_acceptor = if args.enable_tls || args.docker_multi_port { 166 | match tls::load_tls_config() { 167 | Ok(Some(acceptor)) => { 168 | info!("TLS configuration loaded"); 169 | Some(acceptor) 170 | } 171 | Ok(None) => { 172 | if args.docker_multi_port { 173 | error!("Docker multi-port mode requires TLS configuration, but none found"); 174 | return Err(anyhow::anyhow!( 175 | "TLS configuration required for Docker multi-port mode" 176 | )); 177 | } 178 | info!("TLS configuration not found, continuing without TLS"); 179 | None 180 | } 181 | Err(e) => { 182 | error!("Error loading TLS configuration: {}", e); 183 | return Err(e); 184 | } 185 | } 186 | } else { 187 | None 188 | }; 189 | 190 | let config = ServerConfig { 191 | client: Client::new(), 192 | mailpace_endpoint: args.mailpace_endpoint.clone(), 193 | default_mailpace_token: args.default_mailpace_token.clone(), 194 | enable_attachments: args.enable_attachments, 195 | max_attachment_size: args.max_attachment_size, 196 | max_attachments: args.max_attachments, 197 | enable_html_compression: args.enable_html_compression, 198 | }; 199 | 200 | if args.docker_multi_port { 201 | info!("Starting Docker multi-port mode"); 202 | 203 | // Start multiple listeners 204 | let mut handles = vec![]; 205 | 206 | // Port 25 - Standard SMTP with STARTTLS 207 | handles.push(tokio::spawn(start_listener( 208 | "0.0.0.0:25".to_string(), 209 | TlsMode::Starttls, 210 | config.clone(), 211 | tls_acceptor.clone(), 212 | ))); 213 | 214 | // Port 587 - Message Submission with STARTTLS 215 | handles.push(tokio::spawn(start_listener( 216 | "0.0.0.0:587".to_string(), 217 | TlsMode::Starttls, 218 | config.clone(), 219 | tls_acceptor.clone(), 220 | ))); 221 | 222 | // Port 2525 - Alternative SMTP with STARTTLS 223 | handles.push(tokio::spawn(start_listener( 224 | "0.0.0.0:2525".to_string(), 225 | TlsMode::Starttls, 226 | config.clone(), 227 | tls_acceptor.clone(), 228 | ))); 229 | 230 | // Port 465 - SMTP over SSL (implicit TLS) 231 | handles.push(tokio::spawn(start_listener( 232 | "0.0.0.0:465".to_string(), 233 | TlsMode::Implicit, 234 | config.clone(), 235 | tls_acceptor.clone(), 236 | ))); 237 | 238 | // Wait for all listeners 239 | for handle in handles { 240 | if let Err(e) = handle.await? { 241 | error!("Listener error: {}", e); 242 | return Err(e); 243 | } 244 | } 245 | } else { 246 | // Single port mode (original behavior) 247 | info!("Starting single-port mode"); 248 | 249 | let tls_mode = if args.enable_tls { 250 | TlsMode::Starttls 251 | } else { 252 | TlsMode::None 253 | }; 254 | 255 | start_listener(args.listen.clone(), tls_mode, config, tls_acceptor).await?; 256 | } 257 | 258 | Ok(()) 259 | } 260 | -------------------------------------------------------------------------------- /DOCKER.md: -------------------------------------------------------------------------------- 1 | # Docker Multi-Port Setup Guide 2 | 3 | This guide explains how to use Vibe Gateway with Docker in multi-port mode, supporting all standard SMTP ports with appropriate TLS configurations. 4 | 5 | ## Overview 6 | 7 | The Docker multi-port setup provides industry-standard SMTP port configurations: 8 | 9 | | Port | Protocol | Description | TLS Mode | Use Case | 10 | |------|----------|-------------|----------|----------| 11 | | **25** | SMTP | Standard mail transfer | STARTTLS optional | Mail servers, relay agents | 12 | | **587** | Submission | Message submission | STARTTLS optional | Email clients (recommended) | 13 | | **2525** | Alternative | Development/testing | STARTTLS optional | Development, non-standard setups | 14 | | **465** | SMTPS | SMTP over SSL | Implicit TLS | Legacy email clients | 15 | 16 | ## Quick Start 17 | 18 | ### Option 1: Helper Script (Recommended) 19 | 20 | ```bash 21 | # Start multi-port mode 22 | ./docker-run.sh multi-port --token your_mailpace_api_token 23 | 24 | # Start with debug logging 25 | ./docker-run.sh multi-port --dev --token your_mailpace_api_token 26 | 27 | # Start single-port mode (port 2525 only) 28 | ./docker-run.sh single-port --token your_mailpace_api_token 29 | ``` 30 | 31 | ### Option 2: Docker Compose 32 | 33 | ```bash 34 | # Copy and configure environment 35 | cp .env.example .env 36 | # Edit .env file with your API token 37 | 38 | # Start services 39 | docker-compose up -d 40 | 41 | # View logs 42 | docker-compose logs -f 43 | ``` 44 | 45 | ### Option 3: Direct Docker Commands 46 | 47 | ```bash 48 | # Build image 49 | docker build -t vibe-gateway . 50 | 51 | # Run multi-port mode 52 | docker run -d \ 53 | -p 25:25 -p 587:587 -p 2525:2525 -p 465:465 \ 54 | -e MAILPACE_API_TOKEN=your_token \ 55 | vibe-gateway --docker-multi-port 56 | 57 | # Run single-port mode 58 | docker run -d \ 59 | -p 2525:2525 \ 60 | -e MAILPACE_API_TOKEN=your_token \ 61 | vibe-gateway --listen 0.0.0.0:2525 --enable-tls 62 | ``` 63 | 64 | ## Port-Specific Configuration 65 | 66 | ### Port 25 - Standard SMTP 67 | - **Purpose**: Traditional SMTP mail transfer 68 | - **TLS**: STARTTLS optional 69 | - **Use**: Mail servers, postfix relay 70 | - **Client Config**: 71 | ``` 72 | Host: your_server 73 | Port: 25 74 | Encryption: None/STARTTLS 75 | ``` 76 | 77 | ### Port 587 - Message Submission (Recommended) 78 | - **Purpose**: Email client message submission 79 | - **TLS**: STARTTLS optional (recommended) 80 | - **Use**: Email clients, applications 81 | - **Client Config**: 82 | ``` 83 | Host: your_server 84 | Port: 587 85 | Encryption: STARTTLS 86 | Auth: Required 87 | ``` 88 | 89 | ### Port 2525 - Alternative SMTP 90 | - **Purpose**: Development and non-standard setups 91 | - **TLS**: STARTTLS optional 92 | - **Use**: Development, firewalled environments 93 | - **Client Config**: 94 | ``` 95 | Host: your_server 96 | Port: 2525 97 | Encryption: None/STARTTLS 98 | ``` 99 | 100 | ### Port 465 - SMTP over SSL 101 | - **Purpose**: Legacy SMTP over SSL 102 | - **TLS**: Implicit TLS (no STARTTLS command) 103 | - **Use**: Legacy email clients 104 | - **Client Config**: 105 | ``` 106 | Host: your_server 107 | Port: 465 108 | Encryption: SSL/TLS 109 | Auth: Required 110 | ``` 111 | 112 | ## TLS Certificate Management 113 | 114 | ### Development (Default) 115 | The Docker image includes self-signed test certificates for development: 116 | - `test_cert.pem` - Certificate file 117 | - `test_key.pem` - Private key file 118 | 119 | ### Production 120 | For production deployments, mount your own certificates: 121 | 122 | ```bash 123 | # Using Docker run 124 | docker run -d \ 125 | -p 25:25 -p 587:587 -p 2525:2525 -p 465:465 \ 126 | -v /path/to/your/cert.pem:/app/test_cert.pem:ro \ 127 | -v /path/to/your/key.pem:/app/test_key.pem:ro \ 128 | -e MAILPACE_API_TOKEN=your_token \ 129 | vibe-gateway --docker-multi-port 130 | ``` 131 | 132 | ```yaml 133 | # Using Docker Compose override 134 | # docker-compose.override.yml 135 | services: 136 | vibe-gateway: 137 | volumes: 138 | - ./production_cert.pem:/app/test_cert.pem:ro 139 | - ./production_key.pem:/app/test_key.pem:ro 140 | ``` 141 | 142 | ## Environment Variables 143 | 144 | | Variable | Description | Default | Required | 145 | |----------|-------------|---------|----------| 146 | | `MAILPACE_API_TOKEN` | MailPace API token | None | Optional* | 147 | | `MAILPACE_ENDPOINT` | MailPace API endpoint | `https://app.mailpace.com/api/v1/send` | No | 148 | 149 | *Required if users don't authenticate with their own tokens via SMTP AUTH 150 | 151 | ## Health Checks 152 | 153 | The Docker container includes built-in health checks: 154 | 155 | ```bash 156 | # Check container health 157 | docker inspect --format='{{.State.Health.Status}}' container_name 158 | 159 | # Manual health check 160 | docker exec container_name timeout 5 bash -c ' Result> { 10 | let private_key_base64 = std::env::var("PRIVATEKEY"); 11 | let cert_base64 = std::env::var("FULLCHAIN"); 12 | 13 | let (private_key_pem, cert_pem) = match (private_key_base64, cert_base64) { 14 | (Ok(key_b64), Ok(cert_b64)) => { 15 | info!("Loading TLS certificates from environment variables"); 16 | let key_pem = String::from_utf8( 17 | general_purpose::STANDARD 18 | .decode(key_b64) 19 | .context("Failed to decode PRIVATEKEY base64")?, 20 | ) 21 | .context("PRIVATEKEY is not valid UTF-8")?; 22 | 23 | let cert_pem = String::from_utf8( 24 | general_purpose::STANDARD 25 | .decode(cert_b64) 26 | .context("Failed to decode FULLCHAIN base64")?, 27 | ) 28 | .context("FULLCHAIN is not valid UTF-8")?; 29 | 30 | (key_pem, cert_pem) 31 | } 32 | _ => { 33 | info!("TLS environment variables (PRIVATEKEY, FULLCHAIN) not found - TLS will be disabled"); 34 | return Ok(None); 35 | } 36 | }; 37 | 38 | // Parse certificates 39 | let mut cert_reader = std::io::Cursor::new(cert_pem.as_bytes()); 40 | let cert_chain = certs(&mut cert_reader)? 41 | .into_iter() 42 | .map(Certificate) 43 | .collect::>(); 44 | 45 | if cert_chain.is_empty() { 46 | return Err(anyhow::anyhow!("No certificates found")); 47 | } 48 | 49 | // Parse private key 50 | let mut key_reader = std::io::Cursor::new(private_key_pem.as_bytes()); 51 | let private_keys = pkcs8_private_keys(&mut key_reader)?; 52 | 53 | if private_keys.is_empty() { 54 | return Err(anyhow::anyhow!("No private keys found")); 55 | } 56 | 57 | let private_key = PrivateKey(private_keys[0].clone()); 58 | 59 | // Create TLS config 60 | let tls_config = ServerConfig::builder() 61 | .with_safe_defaults() 62 | .with_no_client_auth() 63 | .with_single_cert(cert_chain, private_key) 64 | .context("Failed to create TLS config")?; 65 | 66 | Ok(Some(TlsAcceptor::from(Arc::new(tls_config)))) 67 | } 68 | 69 | #[cfg(test)] 70 | mod tests { 71 | use super::*; 72 | use std::env; 73 | 74 | // Helper function to generate test certificates 75 | fn generate_test_cert() -> (String, String) { 76 | use rcgen::{Certificate, CertificateParams, DistinguishedName}; 77 | 78 | let mut params = CertificateParams::new(vec!["localhost".to_string()]); 79 | params.distinguished_name = DistinguishedName::new(); 80 | params 81 | .distinguished_name 82 | .push(rcgen::DnType::CommonName, "localhost"); 83 | 84 | let cert = Certificate::from_params(params).unwrap(); 85 | let private_key_pem = cert.serialize_private_key_pem(); 86 | let cert_pem = cert.serialize_pem().unwrap(); 87 | 88 | (private_key_pem, cert_pem) 89 | } 90 | 91 | // Helper function to clear environment variables for testing 92 | fn clear_tls_env_vars() { 93 | env::remove_var("PRIVATEKEY"); 94 | env::remove_var("FULLCHAIN"); 95 | } 96 | 97 | // Helper function to set environment variables for testing 98 | fn set_tls_env_vars(private_key_b64: &str, cert_b64: &str) { 99 | env::set_var("PRIVATEKEY", private_key_b64); 100 | env::set_var("FULLCHAIN", cert_b64); 101 | } 102 | 103 | #[test] 104 | fn test_load_tls_config_without_env_vars_returns_none() { 105 | // Ensure no env vars are set 106 | clear_tls_env_vars(); 107 | 108 | let result = load_tls_config(); 109 | assert!(result.is_ok()); 110 | assert!(result.unwrap().is_none()); 111 | } 112 | 113 | #[test] 114 | fn test_load_tls_config_with_valid_env_vars() { 115 | // Generate test certificates 116 | let (private_key_pem, cert_pem) = generate_test_cert(); 117 | let private_key_b64 = general_purpose::STANDARD.encode(&private_key_pem); 118 | let cert_b64 = general_purpose::STANDARD.encode(&cert_pem); 119 | 120 | set_tls_env_vars(&private_key_b64, &cert_b64); 121 | 122 | let result = load_tls_config(); 123 | assert!(result.is_ok()); 124 | assert!(result.unwrap().is_some()); 125 | 126 | // Clean up 127 | clear_tls_env_vars(); 128 | } 129 | 130 | #[test] 131 | fn test_load_tls_config_with_invalid_base64_private_key() { 132 | let (_, cert_pem) = generate_test_cert(); 133 | let cert_b64 = general_purpose::STANDARD.encode(&cert_pem); 134 | 135 | set_tls_env_vars("invalid-base64!", &cert_b64); 136 | 137 | let result = load_tls_config(); 138 | assert!(result.is_err()); 139 | let error_msg = result.err().unwrap().to_string(); 140 | assert!(error_msg.contains("Failed to decode PRIVATEKEY base64")); 141 | 142 | // Clean up 143 | clear_tls_env_vars(); 144 | } 145 | 146 | #[test] 147 | fn test_load_tls_config_with_invalid_base64_cert() { 148 | let (private_key_pem, _) = generate_test_cert(); 149 | let private_key_b64 = general_purpose::STANDARD.encode(&private_key_pem); 150 | 151 | set_tls_env_vars(&private_key_b64, "invalid-base64!"); 152 | 153 | let result = load_tls_config(); 154 | assert!(result.is_err()); 155 | let error_msg = result.err().unwrap().to_string(); 156 | assert!(error_msg.contains("Failed to decode FULLCHAIN base64")); 157 | 158 | // Clean up 159 | clear_tls_env_vars(); 160 | } 161 | 162 | #[test] 163 | fn test_load_tls_config_with_invalid_utf8_private_key() { 164 | let (_, cert_pem) = generate_test_cert(); 165 | let cert_b64 = general_purpose::STANDARD.encode(&cert_pem); 166 | 167 | // Create invalid UTF-8 bytes and encode them as base64 168 | let invalid_utf8 = vec![0xff, 0xfe, 0xfd]; 169 | let invalid_utf8_b64 = general_purpose::STANDARD.encode(&invalid_utf8); 170 | 171 | set_tls_env_vars(&invalid_utf8_b64, &cert_b64); 172 | 173 | let result = load_tls_config(); 174 | assert!(result.is_err()); 175 | let error_msg = result.err().unwrap().to_string(); 176 | assert!(error_msg.contains("PRIVATEKEY is not valid UTF-8")); 177 | 178 | // Clean up 179 | clear_tls_env_vars(); 180 | } 181 | 182 | #[test] 183 | fn test_load_tls_config_with_invalid_utf8_cert() { 184 | let (private_key_pem, _) = generate_test_cert(); 185 | let private_key_b64 = general_purpose::STANDARD.encode(&private_key_pem); 186 | 187 | // Create invalid UTF-8 bytes and encode them as base64 188 | let invalid_utf8 = vec![0xff, 0xfe, 0xfd]; 189 | let invalid_utf8_b64 = general_purpose::STANDARD.encode(&invalid_utf8); 190 | 191 | set_tls_env_vars(&private_key_b64, &invalid_utf8_b64); 192 | 193 | let result = load_tls_config(); 194 | assert!(result.is_err()); 195 | let error_msg = result.err().unwrap().to_string(); 196 | assert!(error_msg.contains("FULLCHAIN is not valid UTF-8")); 197 | 198 | // Clean up 199 | clear_tls_env_vars(); 200 | } 201 | 202 | #[test] 203 | fn test_load_tls_config_with_invalid_certificate_format() { 204 | let (private_key_pem, _) = generate_test_cert(); 205 | let private_key_b64 = general_purpose::STANDARD.encode(&private_key_pem); 206 | 207 | // Create an invalid but PEM-formatted certificate 208 | let invalid_cert = r#"-----BEGIN CERTIFICATE----- 209 | INVALID_CERTIFICATE_DATA_HERE 210 | -----END CERTIFICATE-----"#; 211 | let cert_b64 = general_purpose::STANDARD.encode(invalid_cert); 212 | 213 | set_tls_env_vars(&private_key_b64, &cert_b64); 214 | 215 | let result = load_tls_config(); 216 | // This should fail because the certificate data is invalid 217 | assert!(result.is_err()); 218 | 219 | // Clean up 220 | clear_tls_env_vars(); 221 | } 222 | 223 | #[test] 224 | fn test_load_tls_config_with_empty_certificate() { 225 | let (private_key_pem, _) = generate_test_cert(); 226 | let private_key_b64 = general_purpose::STANDARD.encode(&private_key_pem); 227 | let empty_cert = ""; 228 | let cert_b64 = general_purpose::STANDARD.encode(empty_cert); 229 | 230 | set_tls_env_vars(&private_key_b64, &cert_b64); 231 | 232 | let result = load_tls_config(); 233 | assert!(result.is_err()); 234 | let error_msg = result.err().unwrap().to_string(); 235 | assert!(error_msg.contains("No certificates found")); 236 | 237 | // Clean up 238 | clear_tls_env_vars(); 239 | } 240 | 241 | #[test] 242 | fn test_load_tls_config_with_invalid_private_key_format() { 243 | let (_, cert_pem) = generate_test_cert(); 244 | let cert_b64 = general_purpose::STANDARD.encode(&cert_pem); 245 | let invalid_key = "This is not a valid private key"; 246 | let private_key_b64 = general_purpose::STANDARD.encode(invalid_key); 247 | 248 | set_tls_env_vars(&private_key_b64, &cert_b64); 249 | 250 | let result = load_tls_config(); 251 | assert!(result.is_err()); 252 | 253 | // Clean up 254 | clear_tls_env_vars(); 255 | } 256 | 257 | #[test] 258 | fn test_load_tls_config_with_empty_private_key() { 259 | let (_, cert_pem) = generate_test_cert(); 260 | let cert_b64 = general_purpose::STANDARD.encode(&cert_pem); 261 | let empty_key = ""; 262 | let private_key_b64 = general_purpose::STANDARD.encode(empty_key); 263 | 264 | set_tls_env_vars(&private_key_b64, &cert_b64); 265 | 266 | let result = load_tls_config(); 267 | assert!(result.is_err()); 268 | let error_msg = result.err().unwrap().to_string(); 269 | assert!(error_msg.contains("No private keys found")); 270 | 271 | // Clean up 272 | clear_tls_env_vars(); 273 | } 274 | 275 | #[test] 276 | fn test_load_tls_config_with_only_private_key_env_var() { 277 | let (private_key_pem, _) = generate_test_cert(); 278 | let private_key_b64 = general_purpose::STANDARD.encode(&private_key_pem); 279 | 280 | clear_tls_env_vars(); 281 | env::set_var("PRIVATEKEY", private_key_b64); 282 | // FULLCHAIN is not set 283 | 284 | let result = load_tls_config(); 285 | assert!(result.is_ok()); 286 | assert!(result.unwrap().is_none()); // Should return None since both env vars are required 287 | 288 | // Clean up 289 | clear_tls_env_vars(); 290 | } 291 | 292 | #[test] 293 | fn test_load_tls_config_with_only_cert_env_var() { 294 | let (_, cert_pem) = generate_test_cert(); 295 | let cert_b64 = general_purpose::STANDARD.encode(&cert_pem); 296 | 297 | clear_tls_env_vars(); 298 | env::set_var("FULLCHAIN", cert_b64); 299 | // PRIVATEKEY is not set 300 | 301 | let result = load_tls_config(); 302 | assert!(result.is_ok()); 303 | assert!(result.unwrap().is_none()); // Should return None since both env vars are required 304 | 305 | // Clean up 306 | clear_tls_env_vars(); 307 | } 308 | 309 | #[test] 310 | fn test_generated_certificates_are_valid() { 311 | // Test that the generated certificates are valid by themselves 312 | let (private_key_pem, cert_pem) = generate_test_cert(); 313 | 314 | let mut cert_reader = std::io::Cursor::new(cert_pem.as_bytes()); 315 | let cert_result = certs(&mut cert_reader); 316 | assert!(cert_result.is_ok()); 317 | assert!(!cert_result.unwrap().is_empty()); 318 | 319 | let mut key_reader = std::io::Cursor::new(private_key_pem.as_bytes()); 320 | let key_result = pkcs8_private_keys(&mut key_reader); 321 | assert!(key_result.is_ok()); 322 | assert!(!key_result.unwrap().is_empty()); 323 | } 324 | 325 | #[test] 326 | fn test_tls_config_creation_with_mismatched_cert_key() { 327 | // Generate one set of certs and create a different private key to test mismatch scenario 328 | let (_, cert_pem) = generate_test_cert(); 329 | let (different_private_key, _) = generate_test_cert(); // Generate different cert/key pair 330 | 331 | let private_key_b64 = general_purpose::STANDARD.encode(different_private_key); 332 | let cert_b64 = general_purpose::STANDARD.encode(&cert_pem); 333 | 334 | set_tls_env_vars(&private_key_b64, &cert_b64); 335 | 336 | let result = load_tls_config(); 337 | // This may succeed or fail depending on TLS library validation 338 | // The important thing is it doesn't panic and handles the mismatch gracefully 339 | match result { 340 | Ok(_) => { 341 | // Some TLS libraries may allow mismatched cert/key for testing 342 | } 343 | Err(_) => { 344 | // Some TLS libraries may reject mismatched cert/key 345 | } 346 | } 347 | 348 | // Clean up 349 | clear_tls_env_vars(); 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /tests/html_compression_tests.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use lettre::{ 3 | message::{MultiPart, SinglePart}, 4 | transport::smtp::authentication::Credentials, 5 | Message, Transport, 6 | }; 7 | 8 | mod common; 9 | use common::{create_smtp_transport, TestServer}; 10 | 11 | /// Test HTML compression with basic HTML content 12 | #[tokio::test] 13 | async fn test_basic_html_compression() -> Result<()> { 14 | let server = TestServer::new_with_html_compression().await?; 15 | server.mock_server.setup_success_response().await; 16 | 17 | let transport = create_smtp_transport( 18 | server.smtp_address(), 19 | Some(Credentials::new( 20 | "test-token".to_string(), 21 | "test-token".to_string(), 22 | )), 23 | ); 24 | 25 | // HTML with comments and whitespace that should be compressed 26 | let html_content = r#" 27 | 28 | 29 | 30 | Basic HTML Compression Test 31 | 32 | 33 |

Welcome to Our Service

34 |

This is a test email with extra whitespace.

35 | 36 | 37 | 38 | "#; 39 | 40 | let email = Message::builder() 41 | .from("test@example.com".parse()?) 42 | .to("user@example.com".parse()?) 43 | .subject("Basic HTML Compression Test") 44 | .body(html_content.to_string())?; 45 | 46 | let result = transport.send(&email); 47 | assert!(result.is_ok(), "Basic HTML compression test should succeed"); 48 | 49 | Ok(()) 50 | } 51 | 52 | /// Test HTML compression with multipart emails 53 | #[tokio::test] 54 | async fn test_multipart_html_compression() -> Result<()> { 55 | let server = TestServer::new_with_html_compression().await?; 56 | server.mock_server.setup_success_response().await; 57 | 58 | let transport = create_smtp_transport( 59 | server.smtp_address(), 60 | Some(Credentials::new( 61 | "test-token".to_string(), 62 | "test-token".to_string(), 63 | )), 64 | ); 65 | 66 | let html_part = r#" 67 | 68 | 69 | 70 | 76 | 77 | 78 |
79 |

Multipart HTML Compression

80 |

This HTML part should be compressed in a multipart email.

81 |
82 | 83 | 84 | "#; 85 | 86 | let text_part = 87 | "Multipart HTML Compression\n\nThis HTML part should be compressed in a multipart email."; 88 | 89 | let email = Message::builder() 90 | .from("newsletter@example.com".parse()?) 91 | .to("subscriber@example.com".parse()?) 92 | .subject("Multipart Compression Test") 93 | .multipart( 94 | MultiPart::alternative() 95 | .singlepart(SinglePart::plain(text_part.to_string())) 96 | .singlepart(SinglePart::html(html_part.to_string())), 97 | )?; 98 | 99 | let result = transport.send(&email); 100 | assert!( 101 | result.is_ok(), 102 | "Multipart HTML compression test should succeed" 103 | ); 104 | 105 | Ok(()) 106 | } 107 | 108 | /// Test that plain text is not affected by HTML compression 109 | #[tokio::test] 110 | async fn test_plain_text_passthrough() -> Result<()> { 111 | let server = TestServer::new_with_html_compression().await?; 112 | server.mock_server.setup_success_response().await; 113 | 114 | let transport = create_smtp_transport( 115 | server.smtp_address(), 116 | Some(Credentials::new( 117 | "test-token".to_string(), 118 | "test-token".to_string(), 119 | )), 120 | ); 121 | 122 | let plain_text = "This is plain text content.\nIt should pass through unchanged.\n\nEven with HTML compression enabled."; 123 | 124 | let email = Message::builder() 125 | .from("sender@example.com".parse()?) 126 | .to("recipient@example.com".parse()?) 127 | .subject("Plain Text Passthrough Test") 128 | .body(plain_text.to_string())?; 129 | 130 | let result = transport.send(&email); 131 | assert!(result.is_ok(), "Plain text should pass through unchanged"); 132 | 133 | Ok(()) 134 | } 135 | 136 | /// Test HTML compression with CSS and JavaScript 137 | #[tokio::test] 138 | async fn test_css_javascript_compression() -> Result<()> { 139 | let server = TestServer::new_with_html_compression().await?; 140 | server.mock_server.setup_success_response().await; 141 | 142 | let transport = create_smtp_transport( 143 | server.smtp_address(), 144 | Some(Credentials::new( 145 | "test-token".to_string(), 146 | "test-token".to_string(), 147 | )), 148 | ); 149 | 150 | let html_with_assets = r#" 151 | 152 | 153 | 154 | CSS and JS Compression Test 155 | 175 | 187 | 188 | 189 |
Newsletter Title
190 |
191 |

This email contains both CSS and JavaScript that should be minified.

192 | 193 |
194 | 195 | 196 | "#; 197 | 198 | let email = Message::builder() 199 | .from("newsletter@example.com".parse()?) 200 | .to("user@example.com".parse()?) 201 | .subject("CSS and JS Compression Test") 202 | .body(html_with_assets.to_string())?; 203 | 204 | let result = transport.send(&email); 205 | assert!( 206 | result.is_ok(), 207 | "HTML with CSS and JS should be compressed successfully" 208 | ); 209 | 210 | Ok(()) 211 | } 212 | 213 | /// Test compression with malformed HTML 214 | #[tokio::test] 215 | async fn test_malformed_html_handling() -> Result<()> { 216 | let server = TestServer::new_with_html_compression().await?; 217 | server.mock_server.setup_success_response().await; 218 | 219 | let transport = create_smtp_transport( 220 | server.smtp_address(), 221 | Some(Credentials::new( 222 | "test-token".to_string(), 223 | "test-token".to_string(), 224 | )), 225 | ); 226 | 227 | // Intentionally malformed HTML 228 | let malformed_html = r#" 229 | 230 | 231 | Malformed HTML Test 232 | <!-- Missing closing tag --> 233 | <body> 234 | <h1>Unclosed header 235 | <p>Paragraph without closing tag 236 | <div class="content"> 237 | <span>Nested content 238 | "#; 239 | 240 | let email = Message::builder() 241 | .from("test@example.com".parse()?) 242 | .to("user@example.com".parse()?) 243 | .subject("Malformed HTML Test") 244 | .body(malformed_html.to_string())?; 245 | 246 | let result = transport.send(&email); 247 | assert!( 248 | result.is_ok(), 249 | "Malformed HTML should be handled gracefully" 250 | ); 251 | 252 | Ok(()) 253 | } 254 | 255 | /// Test that compression is disabled when flag is not set 256 | #[tokio::test] 257 | async fn test_compression_disabled_by_default() -> Result<()> { 258 | let server = TestServer::new().await?; // Default server without compression 259 | server.mock_server.setup_success_response().await; 260 | 261 | let transport = create_smtp_transport( 262 | server.smtp_address(), 263 | Some(Credentials::new( 264 | "test-token".to_string(), 265 | "test-token".to_string(), 266 | )), 267 | ); 268 | 269 | let html_content = r#" 270 | <html> 271 | <!-- This comment should remain when compression is disabled --> 272 | <body> 273 | <h1> Uncompressed Content </h1> 274 | <p>This content should not be compressed.</p> 275 | </body> 276 | </html> 277 | "#; 278 | 279 | let email = Message::builder() 280 | .from("test@example.com".parse()?) 281 | .to("user@example.com".parse()?) 282 | .subject("Compression Disabled Test") 283 | .body(html_content.to_string())?; 284 | 285 | let result = transport.send(&email); 286 | assert!(result.is_ok(), "Email should be sent without compression"); 287 | 288 | Ok(()) 289 | } 290 | 291 | /// Test compression with large HTML content 292 | #[tokio::test] 293 | async fn test_large_html_compression() -> Result<()> { 294 | let server = TestServer::new_with_html_compression().await?; 295 | server.mock_server.setup_success_response().await; 296 | 297 | let transport = create_smtp_transport( 298 | server.smtp_address(), 299 | Some(Credentials::new( 300 | "test-token".to_string(), 301 | "test-token".to_string(), 302 | )), 303 | ); 304 | 305 | // Generate large HTML content 306 | let mut large_html = String::from( 307 | r#" 308 | <html> 309 | <head> 310 | <!-- Large HTML compression test --> 311 | <title>Large HTML Email 312 | 318 | 319 | 320 |

Product Catalog

321 |
322 | "#, 323 | ); 324 | 325 | // Add many products to create a large email 326 | for i in 1..=200 { 327 | large_html.push_str(&format!( 328 | r#" 329 |
330 | 331 |
Product {i}
332 |
333 | This is the description for product number {i}. 334 | It contains important information about the product. 335 |
336 |
Price: ${i}.99
337 |
338 | "# 339 | )); 340 | } 341 | 342 | large_html.push_str( 343 | r#" 344 |
345 | 346 | 347 | "#, 348 | ); 349 | 350 | let email = Message::builder() 351 | .from("catalog@example.com".parse()?) 352 | .to("customer@example.com".parse()?) 353 | .subject("Large Product Catalog") 354 | .body(large_html)?; 355 | 356 | let result = transport.send(&email); 357 | assert!( 358 | result.is_ok(), 359 | "Large HTML email should be compressed and sent successfully" 360 | ); 361 | 362 | Ok(()) 363 | } 364 | 365 | /// Test HTML compression with email templates 366 | #[tokio::test] 367 | async fn test_email_template_compression() -> Result<()> { 368 | let server = TestServer::new_with_html_compression().await?; 369 | server.mock_server.setup_success_response().await; 370 | 371 | let transport = create_smtp_transport( 372 | server.smtp_address(), 373 | Some(Credentials::new( 374 | "test-token".to_string(), 375 | "test-token".to_string(), 376 | )), 377 | ); 378 | 379 | // Typical email template with lots of formatting 380 | let template_html = r#" 381 | 382 | 383 | 384 | 385 | 386 | 387 | Welcome Email 388 | 440 | 441 | 442 |
443 |
444 |

Welcome to Our Platform!

445 |
446 | 447 |
448 |

Hello {{user_name}},

449 | 450 |

451 | Thank you for joining our platform. We're excited to have you 452 | as part of our community! 453 |

454 | 455 |

456 | To get started, please click the button below to verify your 457 | email address: 458 |

459 | 460 | 461 | Verify Email Address 462 | 463 | 464 |

465 | If you have any questions, don't hesitate to contact our 466 | support team. 467 |

468 | 469 |

Best regards,
The Platform Team

470 |
471 | 472 | 480 |
481 | 482 | 483 | "#; 484 | 485 | let email = Message::builder() 486 | .from("welcome@platform.com".parse()?) 487 | .to("newuser@example.com".parse()?) 488 | .subject("Welcome to Our Platform!") 489 | .body(template_html.to_string())?; 490 | 491 | let result = transport.send(&email); 492 | assert!( 493 | result.is_ok(), 494 | "Email template should be compressed and sent successfully" 495 | ); 496 | 497 | Ok(()) 498 | } 499 | -------------------------------------------------------------------------------- /tests/performance_tests.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use futures::future::join_all; 3 | use lettre::transport::smtp::authentication::Credentials; 4 | use lettre::{Message, Transport}; 5 | use std::time::{Duration, Instant}; 6 | use tokio::time::sleep; 7 | 8 | mod common; 9 | use common::{create_smtp_transport, TestServer}; 10 | 11 | #[tokio::test] 12 | async fn test_concurrent_email_sending() -> Result<()> { 13 | let server = TestServer::new().await?; 14 | server.mock_server.setup_success_response().await; 15 | 16 | let start_time = Instant::now(); 17 | let num_emails = 10; 18 | 19 | // Create multiple concurrent email sending tasks 20 | let tasks = (0..num_emails).map(|i| { 21 | let server_addr = server.smtp_address(); 22 | tokio::spawn(async move { 23 | let transport = create_smtp_transport( 24 | server_addr, 25 | Some(Credentials::new( 26 | "test-token".to_string(), 27 | "test-token".to_string(), 28 | )), 29 | ); 30 | 31 | let email = Message::builder() 32 | .from(format!("test{i}@example.com").parse().unwrap()) 33 | .to("recipient@example.com".parse().unwrap()) 34 | .subject(format!("Test Subject {i}")) 35 | .body(format!("Test message body {i}")) 36 | .unwrap(); 37 | 38 | transport.send(&email) 39 | }) 40 | }); 41 | 42 | // Wait for all tasks to complete 43 | let results = join_all(tasks).await; 44 | 45 | let duration = start_time.elapsed(); 46 | 47 | // Verify all emails were sent successfully 48 | let successful_sends = results 49 | .into_iter() 50 | .filter_map(|task_result| task_result.ok()) 51 | .filter(|send_result| send_result.is_ok()) 52 | .count(); 53 | 54 | assert_eq!(successful_sends, num_emails); 55 | 56 | // Performance assertion: should complete within reasonable time 57 | assert!( 58 | duration < Duration::from_secs(30), 59 | "Concurrent sends took too long: {duration:?}" 60 | ); 61 | 62 | println!("Sent {num_emails} emails concurrently in {duration:?}"); 63 | 64 | Ok(()) 65 | } 66 | 67 | #[tokio::test] 68 | async fn test_throughput_measurement() -> Result<()> { 69 | let server = TestServer::new().await?; 70 | server.mock_server.setup_success_response().await; 71 | 72 | let transport = create_smtp_transport( 73 | server.smtp_address(), 74 | Some(Credentials::new( 75 | "test-token".to_string(), 76 | "test-token".to_string(), 77 | )), 78 | ); 79 | 80 | let num_emails = 50; 81 | let start_time = Instant::now(); 82 | 83 | // Send emails sequentially to measure throughput 84 | for i in 0..num_emails { 85 | let email = Message::builder() 86 | .from(format!("test{i}@example.com").parse()?) 87 | .to("recipient@example.com".parse()?) 88 | .subject(format!("Throughput Test {i}")) 89 | .body(format!("Test message body {i}"))?; 90 | 91 | let result = transport.send(&email); 92 | assert!(result.is_ok(), "Email {i} should be sent successfully"); 93 | } 94 | 95 | let duration = start_time.elapsed(); 96 | let throughput = num_emails as f64 / duration.as_secs_f64(); 97 | 98 | println!("Throughput: {throughput:.2} emails/second"); 99 | 100 | // Performance assertion: should handle at least 5 emails per second 101 | assert!( 102 | throughput > 5.0, 103 | "Throughput too low: {throughput:.2} emails/second" 104 | ); 105 | 106 | Ok(()) 107 | } 108 | 109 | #[tokio::test] 110 | async fn test_large_email_performance() -> Result<()> { 111 | let server = TestServer::new().await?; 112 | server.mock_server.setup_success_response().await; 113 | 114 | let transport = create_smtp_transport( 115 | server.smtp_address(), 116 | Some(Credentials::new( 117 | "test-token".to_string(), 118 | "test-token".to_string(), 119 | )), 120 | ); 121 | 122 | // Create a large email (1MB) 123 | let large_body = "A".repeat(1024 * 1024); 124 | 125 | let start_time = Instant::now(); 126 | 127 | let email = Message::builder() 128 | .from("test@example.com".parse()?) 129 | .to("recipient@example.com".parse()?) 130 | .subject("Large Email Performance Test") 131 | .body(large_body)?; 132 | 133 | let result = transport.send(&email); 134 | assert!(result.is_ok(), "Large email should be sent successfully"); 135 | 136 | let duration = start_time.elapsed(); 137 | 138 | println!("Large email (1MB) sent in {duration:?}"); 139 | 140 | // Performance assertion: should handle 1MB email within 10 seconds 141 | assert!( 142 | duration < Duration::from_secs(10), 143 | "Large email took too long: {duration:?}" 144 | ); 145 | 146 | Ok(()) 147 | } 148 | 149 | #[tokio::test] 150 | async fn test_connection_handling_under_load() -> Result<()> { 151 | let server = TestServer::new().await?; 152 | server.mock_server.setup_success_response().await; 153 | 154 | let num_connections = 20; 155 | let start_time = Instant::now(); 156 | 157 | // Create multiple connections simultaneously 158 | let tasks = (0..num_connections).map(|i| { 159 | let server_addr = server.smtp_address(); 160 | tokio::spawn(async move { 161 | // Create a new connection for each task 162 | let transport = create_smtp_transport( 163 | server_addr, 164 | Some(Credentials::new( 165 | "test-token".to_string(), 166 | "test-token".to_string(), 167 | )), 168 | ); 169 | 170 | let email = Message::builder() 171 | .from(format!("test{i}@example.com").parse().unwrap()) 172 | .to("recipient@example.com".parse().unwrap()) 173 | .subject(format!("Load Test {i}")) 174 | .body(format!("Load test message {i}")) 175 | .unwrap(); 176 | 177 | transport.send(&email) 178 | }) 179 | }); 180 | 181 | let results = join_all(tasks).await; 182 | let duration = start_time.elapsed(); 183 | 184 | // Verify all connections were handled successfully 185 | let successful_sends = results 186 | .into_iter() 187 | .filter_map(|task_result| task_result.ok()) 188 | .filter(|send_result| send_result.is_ok()) 189 | .count(); 190 | 191 | assert_eq!(successful_sends, num_connections); 192 | 193 | println!("Handled {num_connections} concurrent connections in {duration:?}"); 194 | 195 | // Performance assertion: should handle multiple connections efficiently 196 | assert!( 197 | duration < Duration::from_secs(60), 198 | "Connection handling took too long: {duration:?}" 199 | ); 200 | 201 | Ok(()) 202 | } 203 | 204 | #[tokio::test] 205 | async fn test_memory_usage_with_attachments() -> Result<()> { 206 | let server = TestServer::new().await?; 207 | server.mock_server.setup_success_response().await; 208 | 209 | let transport = create_smtp_transport( 210 | server.smtp_address(), 211 | Some(Credentials::new( 212 | "test-token".to_string(), 213 | "test-token".to_string(), 214 | )), 215 | ); 216 | 217 | // Create email with multiple attachments 218 | let attachment_size = 100 * 1024; // 100KB per attachment 219 | let num_attachments = 5; 220 | 221 | let start_time = Instant::now(); 222 | 223 | let mut multipart = lettre::message::MultiPart::mixed().singlepart( 224 | lettre::message::SinglePart::plain("Email with multiple attachments".to_string()), 225 | ); 226 | 227 | for i in 0..num_attachments { 228 | let attachment_content = 229 | format!("Attachment {} content: {}", i, "X".repeat(attachment_size)); 230 | let attachment = lettre::message::Attachment::new(format!("attachment_{i}.txt")).body( 231 | attachment_content, 232 | lettre::message::header::ContentType::TEXT_PLAIN, 233 | ); 234 | multipart = multipart.singlepart(attachment); 235 | } 236 | 237 | let email = Message::builder() 238 | .from("test@example.com".parse()?) 239 | .to("recipient@example.com".parse()?) 240 | .subject("Memory Usage Test") 241 | .multipart(multipart)?; 242 | 243 | let result = transport.send(&email); 244 | assert!( 245 | result.is_ok(), 246 | "Email with attachments should be sent successfully" 247 | ); 248 | 249 | let duration = start_time.elapsed(); 250 | 251 | println!( 252 | "Email with {} attachments ({}KB each) sent in {:?}", 253 | num_attachments, 254 | attachment_size / 1024, 255 | duration 256 | ); 257 | 258 | // Performance assertion: should handle attachments efficiently 259 | assert!( 260 | duration < Duration::from_secs(30), 261 | "Attachment processing took too long: {duration:?}" 262 | ); 263 | 264 | Ok(()) 265 | } 266 | 267 | #[tokio::test] 268 | async fn test_stress_test_rapid_emails() -> Result<()> { 269 | let server = TestServer::new().await?; 270 | server.mock_server.setup_success_response().await; 271 | 272 | let transport = create_smtp_transport( 273 | server.smtp_address(), 274 | Some(Credentials::new( 275 | "test-token".to_string(), 276 | "test-token".to_string(), 277 | )), 278 | ); 279 | 280 | let num_emails = 100; 281 | let start_time = Instant::now(); 282 | 283 | // Send emails as fast as possible 284 | for i in 0..num_emails { 285 | let email = Message::builder() 286 | .from(format!("stress{i}@example.com").parse()?) 287 | .to("recipient@example.com".parse()?) 288 | .subject(format!("Stress Test {i}")) 289 | .body(format!("Stress test message {i}"))?; 290 | 291 | let result = transport.send(&email); 292 | assert!( 293 | result.is_ok(), 294 | "Stress test email {i} should be sent successfully" 295 | ); 296 | 297 | // Small delay to prevent overwhelming the system 298 | sleep(Duration::from_millis(10)).await; 299 | } 300 | 301 | let duration = start_time.elapsed(); 302 | let throughput = num_emails as f64 / duration.as_secs_f64(); 303 | 304 | println!("Stress test: {num_emails} emails in {duration:?} ({throughput:.2} emails/second)"); 305 | 306 | // Performance assertion: should handle rapid emails 307 | assert!( 308 | duration < Duration::from_secs(120), 309 | "Stress test took too long: {duration:?}" 310 | ); 311 | 312 | Ok(()) 313 | } 314 | 315 | // HTML Compression Performance Tests 316 | #[tokio::test] 317 | async fn test_html_compression_performance() -> Result<()> { 318 | let server = TestServer::new_with_html_compression().await?; 319 | server.mock_server.setup_success_response().await; 320 | 321 | // Create a moderately large HTML email for compression testing 322 | let html_content = generate_test_html_email(1000); // 1000 elements 323 | 324 | let start_time = Instant::now(); 325 | let num_emails = 20; 326 | 327 | // Send multiple HTML emails to test compression performance 328 | let tasks = (0..num_emails).map(|i| { 329 | let transport = create_smtp_transport( 330 | server.smtp_address(), 331 | Some(Credentials::new( 332 | "test-token".to_string(), 333 | "test-token".to_string(), 334 | )), 335 | ); 336 | let html_content = html_content.clone(); 337 | 338 | tokio::spawn(async move { 339 | let email = Message::builder() 340 | .from(format!("perf-test-{i}@example.com").parse().unwrap()) 341 | .to("recipient@example.com".parse().unwrap()) 342 | .subject(format!("HTML Compression Performance Test {i}")) 343 | .body(html_content) 344 | .unwrap(); 345 | 346 | transport.send(&email) 347 | }) 348 | }); 349 | 350 | let results = join_all(tasks).await; 351 | let duration = start_time.elapsed(); 352 | 353 | // Verify all emails were sent successfully 354 | let successful_sends = results 355 | .into_iter() 356 | .filter_map(|task_result| task_result.ok()) 357 | .filter(|send_result| send_result.is_ok()) 358 | .count(); 359 | 360 | assert_eq!( 361 | successful_sends, num_emails, 362 | "All HTML emails should be sent successfully" 363 | ); 364 | 365 | let throughput = num_emails as f64 / duration.as_secs_f64(); 366 | println!("HTML compression performance: {num_emails} emails in {duration:?} ({throughput:.2} emails/second)"); 367 | 368 | // Performance assertion: compression shouldn't significantly impact performance 369 | assert!( 370 | duration < Duration::from_secs(60), 371 | "HTML compression performance test took too long: {duration:?}" 372 | ); 373 | 374 | Ok(()) 375 | } 376 | 377 | #[tokio::test] 378 | async fn test_compression_vs_no_compression_performance() -> Result<()> { 379 | // Test with compression enabled 380 | let server_with_compression = TestServer::new_with_html_compression().await?; 381 | server_with_compression 382 | .mock_server 383 | .setup_success_response() 384 | .await; 385 | 386 | let html_content = generate_test_html_email(500); 387 | let num_emails = 10; 388 | 389 | // Test with compression 390 | let start_time = Instant::now(); 391 | let tasks_with_compression = (0..num_emails).map(|i| { 392 | let transport = create_smtp_transport( 393 | server_with_compression.smtp_address(), 394 | Some(Credentials::new( 395 | "test-token".to_string(), 396 | "test-token".to_string(), 397 | )), 398 | ); 399 | let html_content = html_content.clone(); 400 | 401 | tokio::spawn(async move { 402 | let email = Message::builder() 403 | .from(format!("comp-test-{i}@example.com").parse().unwrap()) 404 | .to("recipient@example.com".parse().unwrap()) 405 | .subject(format!("Compression Test {i}")) 406 | .body(html_content) 407 | .unwrap(); 408 | 409 | transport.send(&email) 410 | }) 411 | }); 412 | 413 | let _results_with_compression = join_all(tasks_with_compression).await; 414 | let duration_with_compression = start_time.elapsed(); 415 | 416 | // Clean up 417 | drop(server_with_compression); 418 | sleep(Duration::from_millis(500)).await; 419 | 420 | // Test without compression 421 | let server_without_compression = TestServer::new().await?; 422 | server_without_compression 423 | .mock_server 424 | .setup_success_response() 425 | .await; 426 | 427 | let start_time = Instant::now(); 428 | let tasks_without_compression = (0..num_emails).map(|i| { 429 | let transport = create_smtp_transport( 430 | server_without_compression.smtp_address(), 431 | Some(Credentials::new( 432 | "test-token".to_string(), 433 | "test-token".to_string(), 434 | )), 435 | ); 436 | let html_content = html_content.clone(); 437 | 438 | tokio::spawn(async move { 439 | let email = Message::builder() 440 | .from(format!("no-comp-test-{i}@example.com").parse().unwrap()) 441 | .to("recipient@example.com".parse().unwrap()) 442 | .subject(format!("No Compression Test {i}")) 443 | .body(html_content) 444 | .unwrap(); 445 | 446 | transport.send(&email) 447 | }) 448 | }); 449 | 450 | let _results_without_compression = join_all(tasks_without_compression).await; 451 | let duration_without_compression = start_time.elapsed(); 452 | 453 | println!("With compression: {duration_with_compression:?}"); 454 | println!("Without compression: {duration_without_compression:?}"); 455 | 456 | // Compression shouldn't add more than 50% overhead 457 | let overhead_ratio = 458 | duration_with_compression.as_secs_f64() / duration_without_compression.as_secs_f64(); 459 | assert!( 460 | overhead_ratio < 1.5, 461 | "HTML compression adds too much overhead: {overhead_ratio:.2}x" 462 | ); 463 | 464 | Ok(()) 465 | } 466 | 467 | #[tokio::test] 468 | async fn test_large_html_compression_performance() -> Result<()> { 469 | let server = TestServer::new_with_html_compression().await?; 470 | server.mock_server.setup_success_response().await; 471 | 472 | let transport = create_smtp_transport( 473 | server.smtp_address(), 474 | Some(Credentials::new( 475 | "test-token".to_string(), 476 | "test-token".to_string(), 477 | )), 478 | ); 479 | 480 | // Generate very large HTML content 481 | let large_html = generate_test_html_email(5000); // 5000 elements - very large 482 | 483 | let start_time = Instant::now(); 484 | 485 | let email = Message::builder() 486 | .from("large-test@example.com".parse()?) 487 | .to("recipient@example.com".parse()?) 488 | .subject("Large HTML Compression Test") 489 | .body(large_html)?; 490 | 491 | let result = transport.send(&email); 492 | let duration = start_time.elapsed(); 493 | 494 | assert!( 495 | result.is_ok(), 496 | "Large HTML email should be sent successfully" 497 | ); 498 | 499 | println!("Large HTML compression: {duration:?}"); 500 | 501 | // Even large HTML should be processed reasonably quickly 502 | assert!( 503 | duration < Duration::from_secs(30), 504 | "Large HTML compression took too long: {duration:?}" 505 | ); 506 | 507 | Ok(()) 508 | } 509 | 510 | /// Helper function to generate test HTML content of varying sizes 511 | fn generate_test_html_email(num_elements: usize) -> String { 512 | let mut html = String::from( 513 | r#" 514 | 515 | 516 | 517 | 518 | Performance Test Email 519 | 545 | 546 | 547 |

Performance Test Email

548 |
549 | "#, 550 | ); 551 | 552 | // Add many HTML elements to test compression performance 553 | for i in 0..num_elements { 554 | html.push_str(&format!( 555 | r#" 556 |
557 | 558 |

Item Number {}

559 |

560 | This is item number {} with some descriptive text. 561 | It contains highlighted content 562 | and other formatting elements to test compression. 563 |

564 |
565 | Created: 2024-07-{:02} 566 |
567 |
568 | "#, 569 | i, 570 | i, 571 | i, 572 | (i % 30) + 1 573 | )); 574 | 575 | // Add some variety every 50 items 576 | if i % 50 == 0 { 577 | html.push_str(&format!( 578 | r#" 579 |
580 | 581 |
582 |

Section {}

583 |
584 | "#, 585 | i / 50, 586 | i / 50 587 | )); 588 | } 589 | } 590 | 591 | html.push_str( 592 | r#" 593 |
594 |
595 | 596 |

End of performance test email

597 |
598 | 599 | 600 | "#, 601 | ); 602 | 603 | html 604 | } 605 | -------------------------------------------------------------------------------- /tests/integration_tests.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use lettre::{ 3 | message::{header::ContentType, Attachment, MultiPart, SinglePart}, 4 | transport::smtp::authentication::Credentials, 5 | Message, Transport, 6 | }; 7 | 8 | mod common; 9 | use common::{create_smtp_transport, TestServer}; 10 | 11 | #[tokio::test] 12 | async fn test_basic_email_sending() -> Result<()> { 13 | let server = TestServer::new().await?; 14 | server.mock_server.setup_success_response().await; 15 | 16 | let transport = create_smtp_transport( 17 | server.smtp_address(), 18 | Some(Credentials::new( 19 | "test-token".to_string(), 20 | "test-token".to_string(), 21 | )), 22 | ); 23 | 24 | let email = Message::builder() 25 | .from("test@example.com".parse()?) 26 | .to("recipient@example.com".parse()?) 27 | .subject("Test Subject") 28 | .body("Test message body".to_string())?; 29 | 30 | let result = transport.send(&email); 31 | assert!(result.is_ok(), "Email should be sent successfully"); 32 | 33 | Ok(()) 34 | } 35 | 36 | #[tokio::test] 37 | async fn test_email_with_html_content() -> Result<()> { 38 | let server = TestServer::new().await?; 39 | server.mock_server.setup_success_response().await; 40 | 41 | let transport = create_smtp_transport( 42 | server.smtp_address(), 43 | Some(Credentials::new( 44 | "test-token".to_string(), 45 | "test-token".to_string(), 46 | )), 47 | ); 48 | 49 | let html_body = r#"

Test HTML

This is a test email with HTML content.

"#; 50 | let text_body = "Test HTML\n\nThis is a test email with HTML content."; 51 | 52 | let email = Message::builder() 53 | .from("test@example.com".parse()?) 54 | .to("recipient@example.com".parse()?) 55 | .subject("HTML Test Subject") 56 | .multipart( 57 | MultiPart::alternative() 58 | .singlepart(SinglePart::plain(text_body.to_string())) 59 | .singlepart(SinglePart::html(html_body.to_string())), 60 | )?; 61 | 62 | let result = transport.send(&email); 63 | assert!(result.is_ok(), "HTML email should be sent successfully"); 64 | 65 | Ok(()) 66 | } 67 | 68 | #[tokio::test] 69 | async fn test_email_with_attachment() -> Result<()> { 70 | let server = TestServer::new().await?; 71 | server.mock_server.setup_success_response().await; 72 | 73 | let transport = create_smtp_transport( 74 | server.smtp_address(), 75 | Some(Credentials::new( 76 | "test-token".to_string(), 77 | "test-token".to_string(), 78 | )), 79 | ); 80 | 81 | let attachment_content = b"This is a test attachment content."; 82 | let attachment = Attachment::new("test.txt".to_string()) 83 | .body(attachment_content.to_vec(), ContentType::TEXT_PLAIN); 84 | 85 | let email = Message::builder() 86 | .from("test@example.com".parse()?) 87 | .to("recipient@example.com".parse()?) 88 | .subject("Test with Attachment") 89 | .multipart( 90 | MultiPart::mixed() 91 | .singlepart(SinglePart::plain("Email with attachment.".to_string())) 92 | .singlepart(attachment), 93 | )?; 94 | 95 | let result = transport.send(&email); 96 | assert!( 97 | result.is_ok(), 98 | "Email with attachment should be sent successfully" 99 | ); 100 | 101 | Ok(()) 102 | } 103 | 104 | #[tokio::test] 105 | async fn test_email_with_mailpace_headers() -> Result<()> { 106 | let server = TestServer::new().await?; 107 | server.mock_server.setup_success_response().await; 108 | 109 | let transport = create_smtp_transport( 110 | server.smtp_address(), 111 | Some(Credentials::new( 112 | "test-token".to_string(), 113 | "test-token".to_string(), 114 | )), 115 | ); 116 | 117 | // For now, let's simplify this test to not use custom headers 118 | // Custom headers need to be implemented properly in the SMTP parsing layer 119 | let email = Message::builder() 120 | .from("test@example.com".parse()?) 121 | .to("recipient@example.com".parse()?) 122 | .subject("Test with MailPace Headers") 123 | .body("Test message with MailPace headers".to_string())?; 124 | 125 | let result = transport.send(&email); 126 | assert!( 127 | result.is_ok(), 128 | "Email with MailPace headers should be sent successfully" 129 | ); 130 | 131 | Ok(()) 132 | } 133 | 134 | #[tokio::test] 135 | async fn test_authentication_failure() -> Result<()> { 136 | let server = TestServer::new().await?; 137 | server 138 | .mock_server 139 | .setup_error_response(401, "Unauthorized") 140 | .await; 141 | 142 | let transport = create_smtp_transport( 143 | server.smtp_address(), 144 | Some(Credentials::new( 145 | "wrong-token".to_string(), 146 | "wrong-token".to_string(), 147 | )), 148 | ); 149 | 150 | let email = Message::builder() 151 | .from("test@example.com".parse()?) 152 | .to("recipient@example.com".parse()?) 153 | .subject("Test Subject") 154 | .body("Test message body".to_string())?; 155 | 156 | let result = transport.send(&email); 157 | // The SMTP gateway should handle API authentication errors 158 | // This test verifies the gateway handles 401 responses properly 159 | let _ = result; 160 | 161 | Ok(()) 162 | } 163 | 164 | #[tokio::test] 165 | async fn test_multiple_recipients() -> Result<()> { 166 | let server = TestServer::new().await?; 167 | server.mock_server.setup_success_response().await; 168 | 169 | let transport = create_smtp_transport( 170 | server.smtp_address(), 171 | Some(Credentials::new( 172 | "test-token".to_string(), 173 | "test-token".to_string(), 174 | )), 175 | ); 176 | 177 | let email = Message::builder() 178 | .from("test@example.com".parse()?) 179 | .to("recipient1@example.com".parse()?) 180 | .to("recipient2@example.com".parse()?) 181 | .cc("cc@example.com".parse()?) 182 | .subject("Test Multiple Recipients") 183 | .body("Test message to multiple recipients".to_string())?; 184 | 185 | let result = transport.send(&email); 186 | assert!( 187 | result.is_ok(), 188 | "Email to multiple recipients should be sent successfully" 189 | ); 190 | 191 | Ok(()) 192 | } 193 | 194 | #[tokio::test] 195 | async fn test_large_email_content() -> Result<()> { 196 | let server = TestServer::new().await?; 197 | server.mock_server.setup_success_response().await; 198 | 199 | let transport = create_smtp_transport( 200 | server.smtp_address(), 201 | Some(Credentials::new( 202 | "test-token".to_string(), 203 | "test-token".to_string(), 204 | )), 205 | ); 206 | 207 | // Create a large email body 208 | let large_body = "A".repeat(50000); // 50KB of content 209 | 210 | let email = Message::builder() 211 | .from("test@example.com".parse()?) 212 | .to("recipient@example.com".parse()?) 213 | .subject("Test Large Email") 214 | .body(large_body.clone())?; 215 | 216 | let result = transport.send(&email); 217 | assert!(result.is_ok(), "Large email should be sent successfully"); 218 | 219 | Ok(()) 220 | } 221 | 222 | #[tokio::test] 223 | async fn test_smtp_commands_ehlo() -> Result<()> { 224 | let server = TestServer::new().await?; 225 | 226 | // Test direct SMTP commands 227 | let stream = tokio::net::TcpStream::connect(server.smtp_address()).await?; 228 | use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; 229 | 230 | let (reader, mut writer) = stream.into_split(); 231 | let mut buf_reader = BufReader::new(reader); 232 | let mut response = String::new(); 233 | 234 | // Read welcome message 235 | buf_reader.read_line(&mut response).await?; 236 | assert!(response.contains("220"), "Should receive welcome message"); 237 | 238 | // Send EHLO 239 | writer.write_all(b"EHLO test.example.com\r\n").await?; 240 | 241 | // Read EHLO response 242 | response.clear(); 243 | buf_reader.read_line(&mut response).await?; 244 | assert!( 245 | response.contains("250"), 246 | "Should receive positive response to EHLO" 247 | ); 248 | 249 | // Send QUIT 250 | writer.write_all(b"QUIT\r\n").await?; 251 | 252 | Ok(()) 253 | } 254 | 255 | #[tokio::test] 256 | async fn test_default_mailpace_token() -> Result<()> { 257 | let server = TestServer::new().await?; 258 | server.mock_server.setup_success_response().await; 259 | 260 | // Test without providing credentials (should use default token) 261 | let transport = create_smtp_transport(server.smtp_address(), None); 262 | 263 | let email = Message::builder() 264 | .from("test@example.com".parse()?) 265 | .to("recipient@example.com".parse()?) 266 | .subject("Test Default Token") 267 | .body("Test message with default token".to_string())?; 268 | 269 | let result = transport.send(&email); 270 | assert!(result.is_ok(), "Email should be sent using default token"); 271 | 272 | Ok(()) 273 | } 274 | 275 | // HTML Compression Tests 276 | #[tokio::test] 277 | async fn test_html_compression_basic() -> Result<()> { 278 | let server = TestServer::new_with_html_compression().await?; 279 | server.mock_server.setup_success_response().await; 280 | 281 | let transport = create_smtp_transport( 282 | server.smtp_address(), 283 | Some(Credentials::new( 284 | "test-token".to_string(), 285 | "test-token".to_string(), 286 | )), 287 | ); 288 | 289 | // Create HTML with whitespace and comments that should be compressed 290 | let html_body = r#" 291 | 292 | 293 | 294 | Test Email 295 | 301 | 302 | 303 |

Hello World

304 |

This is a test email with lots of whitespace.

305 | 306 | 307 | 308 | "#; 309 | 310 | let email = Message::builder() 311 | .from("test@example.com".parse()?) 312 | .to("recipient@example.com".parse()?) 313 | .subject("HTML Compression Test") 314 | .body(html_body.to_string())?; 315 | 316 | let result = transport.send(&email); 317 | assert!(result.is_ok(), "HTML email should be sent successfully"); 318 | 319 | Ok(()) 320 | } 321 | 322 | #[tokio::test] 323 | async fn test_html_compression_multipart() -> Result<()> { 324 | let server = TestServer::new_with_html_compression().await?; 325 | server.mock_server.setup_success_response().await; 326 | 327 | let transport = create_smtp_transport( 328 | server.smtp_address(), 329 | Some(Credentials::new( 330 | "test-token".to_string(), 331 | "test-token".to_string(), 332 | )), 333 | ); 334 | 335 | // Create multipart email with both text and HTML 336 | let html_body = r#" 337 | 338 | 339 | 340 | Multipart Test 341 | 342 | 343 |
344 |

HTML Compression in Multipart

345 |

This HTML should be compressed.

346 | 347 |
348 | 349 | 350 | "#; 351 | 352 | let text_body = "HTML Compression in Multipart\n\nThis HTML should be compressed."; 353 | 354 | let email = Message::builder() 355 | .from("test@example.com".parse()?) 356 | .to("recipient@example.com".parse()?) 357 | .subject("Multipart HTML Compression Test") 358 | .multipart( 359 | MultiPart::alternative() 360 | .singlepart(SinglePart::plain(text_body.to_string())) 361 | .singlepart(SinglePart::html(html_body.to_string())), 362 | )?; 363 | 364 | let result = transport.send(&email); 365 | assert!( 366 | result.is_ok(), 367 | "Multipart HTML email should be sent successfully" 368 | ); 369 | 370 | Ok(()) 371 | } 372 | 373 | #[tokio::test] 374 | async fn test_html_compression_with_inline_styles() -> Result<()> { 375 | let server = TestServer::new_with_html_compression().await?; 376 | server.mock_server.setup_success_response().await; 377 | 378 | let transport = create_smtp_transport( 379 | server.smtp_address(), 380 | Some(Credentials::new( 381 | "test-token".to_string(), 382 | "test-token".to_string(), 383 | )), 384 | ); 385 | 386 | // HTML with inline styles that should be minified 387 | let html_body = r#" 388 | 389 | 390 | 391 | 402 | 403 | 404 |
405 | Welcome to Our Newsletter 406 |
407 |
408 |

409 | This email contains CSS that should be minified. 410 |

411 |
412 | 413 | 414 | "#; 415 | 416 | let email = Message::builder() 417 | .from("newsletter@example.com".parse()?) 418 | .to("subscriber@example.com".parse()?) 419 | .subject("Newsletter with CSS") 420 | .body(html_body.to_string())?; 421 | 422 | let result = transport.send(&email); 423 | assert!( 424 | result.is_ok(), 425 | "HTML email with CSS should be sent successfully" 426 | ); 427 | 428 | Ok(()) 429 | } 430 | 431 | #[tokio::test] 432 | async fn test_html_compression_disabled() -> Result<()> { 433 | // Test with compression disabled (default server) 434 | let server = TestServer::new().await?; 435 | server.mock_server.setup_success_response().await; 436 | 437 | let transport = create_smtp_transport( 438 | server.smtp_address(), 439 | Some(Credentials::new( 440 | "test-token".to_string(), 441 | "test-token".to_string(), 442 | )), 443 | ); 444 | 445 | let html_body = r#" 446 | 447 | 448 | 449 |

Uncompressed HTML

450 | 451 | 452 | "#; 453 | 454 | let email = Message::builder() 455 | .from("test@example.com".parse()?) 456 | .to("recipient@example.com".parse()?) 457 | .subject("Uncompressed HTML Test") 458 | .body(html_body.to_string())?; 459 | 460 | let result = transport.send(&email); 461 | assert!( 462 | result.is_ok(), 463 | "HTML email should be sent successfully without compression" 464 | ); 465 | 466 | Ok(()) 467 | } 468 | 469 | #[tokio::test] 470 | async fn test_plain_text_with_compression_enabled() -> Result<()> { 471 | let server = TestServer::new_with_html_compression().await?; 472 | server.mock_server.setup_success_response().await; 473 | 474 | let transport = create_smtp_transport( 475 | server.smtp_address(), 476 | Some(Credentials::new( 477 | "test-token".to_string(), 478 | "test-token".to_string(), 479 | )), 480 | ); 481 | 482 | // Plain text should pass through unchanged even when compression is enabled 483 | let text_body = "This is just plain text content.\nIt should not be affected by HTML compression.\n\nSincerely,\nThe Test Team"; 484 | 485 | let email = Message::builder() 486 | .from("test@example.com".parse()?) 487 | .to("recipient@example.com".parse()?) 488 | .subject("Plain Text Test") 489 | .body(text_body.to_string())?; 490 | 491 | let result = transport.send(&email); 492 | assert!( 493 | result.is_ok(), 494 | "Plain text email should be sent successfully" 495 | ); 496 | 497 | Ok(()) 498 | } 499 | 500 | #[tokio::test] 501 | async fn test_html_compression_with_javascript() -> Result<()> { 502 | let server = TestServer::new_with_html_compression().await?; 503 | server.mock_server.setup_success_response().await; 504 | 505 | let transport = create_smtp_transport( 506 | server.smtp_address(), 507 | Some(Credentials::new( 508 | "test-token".to_string(), 509 | "test-token".to_string(), 510 | )), 511 | ); 512 | 513 | // HTML with inline JavaScript that should be minified 514 | let html_body = r#" 515 | 516 | 517 | 527 | 528 | 529 |

Email with JavaScript

530 |

This email contains JavaScript that should be minified.

531 | 532 | 533 | "#; 534 | 535 | let email = Message::builder() 536 | .from("interactive@example.com".parse()?) 537 | .to("user@example.com".parse()?) 538 | .subject("Interactive Email") 539 | .body(html_body.to_string())?; 540 | 541 | let result = transport.send(&email); 542 | assert!( 543 | result.is_ok(), 544 | "HTML email with JavaScript should be sent successfully" 545 | ); 546 | 547 | Ok(()) 548 | } 549 | 550 | #[tokio::test] 551 | async fn test_html_compression_malformed_html() -> Result<()> { 552 | let server = TestServer::new_with_html_compression().await?; 553 | server.mock_server.setup_success_response().await; 554 | 555 | let transport = create_smtp_transport( 556 | server.smtp_address(), 557 | Some(Credentials::new( 558 | "test-token".to_string(), 559 | "test-token".to_string(), 560 | )), 561 | ); 562 | 563 | // Malformed HTML should still be handled gracefully 564 | let malformed_html = r#" 565 | 566 | Malformed HTML 567 | <body> 568 | <h1>Missing closing tags 569 | <p>This is malformed HTML that should still work 570 | <div>Unclosed div 571 | "#; 572 | 573 | let email = Message::builder() 574 | .from("test@example.com".parse()?) 575 | .to("recipient@example.com".parse()?) 576 | .subject("Malformed HTML Test") 577 | .body(malformed_html.to_string())?; 578 | 579 | let result = transport.send(&email); 580 | assert!( 581 | result.is_ok(), 582 | "Malformed HTML email should be sent successfully with fallback" 583 | ); 584 | 585 | Ok(()) 586 | } 587 | 588 | #[tokio::test] 589 | async fn test_html_compression_large_html() -> Result<()> { 590 | let server = TestServer::new_with_html_compression().await?; 591 | server.mock_server.setup_success_response().await; 592 | 593 | let transport = create_smtp_transport( 594 | server.smtp_address(), 595 | Some(Credentials::new( 596 | "test-token".to_string(), 597 | "test-token".to_string(), 598 | )), 599 | ); 600 | 601 | // Generate a large HTML email 602 | let mut html_body = String::from( 603 | r#" 604 | <html> 605 | <head> 606 | <!-- Large HTML compression test --> 607 | <title>Large HTML Email 608 | 612 | 613 | 614 |

Large HTML Content Test

615 | "#, 616 | ); 617 | 618 | // Add many repetitive HTML elements 619 | for i in 0..100 { 620 | html_body.push_str(&format!( 621 | r#" 622 |
623 | 624 |

Item {i}

625 |

This is item number {i} with extra whitespace.

626 |
627 | "# 628 | )); 629 | } 630 | 631 | html_body.push_str( 632 | r#" 633 | 634 | 635 | "#, 636 | ); 637 | 638 | let email = Message::builder() 639 | .from("bulk@example.com".parse()?) 640 | .to("recipient@example.com".parse()?) 641 | .subject("Large HTML Test") 642 | .body(html_body)?; 643 | 644 | let result = transport.send(&email); 645 | assert!( 646 | result.is_ok(), 647 | "Large HTML email should be sent successfully" 648 | ); 649 | 650 | Ok(()) 651 | } 652 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | MailPace Logo 3 | 4 | # Vibe Gateway - SMTP to MailPace Bridge 5 | 6 | ### A high-performance Rust SMTP server that seamlessly bridges email delivery to the MailPace API 7 | 8 | [![CI](https://github.com/mailpace/vibe-smtp/actions/workflows/ci.yml/badge.svg)](https://github.com/mailpace/vibe-smtp/actions/workflows/ci.yml) 9 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 10 | [![Rust](https://img.shields.io/badge/rust-1.88+-orange.svg)](https://www.rust-lang.org) 11 | [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://hub.docker.com) 12 | 13 | [**Website**](https://mailpace.com) • [**Documentation**](https://docs.mailpace.com) • [**API Reference**](https://docs.mailpace.com/reference/send) • [**Support**](mailto:support@mailpace.com) 14 |
15 | 16 | --- 17 | 18 | ## Overview 19 | 20 | A production-ready Rust SMTP server that accepts emails and forwards them to the MailPace API with enterprise-grade reliability and performance. 21 | 22 | ## 📑 Table of Contents 23 | 24 | - [Key Features](#-key-features) 25 | - [Quick Start](#-quick-start) 26 | - [Configuration](#-configuration) 27 | - [Authentication](#-authentication) 28 | - [SMTP Client Configuration](#-smtp-client-configuration) 29 | - [Usage](#usage) 30 | - [MailPace Features](#mailpace-features) 31 | - [Attachment Support](#attachment-support) 32 | - [HTML Compression](#html-compression) 33 | - [Error Handling](#error-handling) 34 | - [Development](#-development) 35 | - [Testing](#-testing) 36 | - [License](#-license) 37 | 38 | ## ✨ Key Features 39 | 40 | - 🚀 **High-Performance SMTP Server** - Built with Rust for maximum throughput and reliability 41 | - 🔐 **Enterprise Authentication** - Full SMTP authentication with MailPace API token integration 42 | - 📎 **Smart Attachment Handling** - Automatic MIME parsing with configurable size limits 43 | - 🗜️ **HTML Compression** - Intelligent compression optimized for email clients 44 | - 🔒 **TLS/STARTTLS Support** - Secure email transmission with modern encryption 45 | - 📊 **Advanced Monitoring** - Comprehensive logging and error reporting 46 | - 🏷️ **MailPace Integration** - Native support for tags, list-unsubscribe, and custom headers 47 | - ⚡ **Zero-Downtime Deployment** - Docker-ready with health checks and graceful shutdown 48 | 49 | ## 🚀 Quick Start 50 | 51 | ### Prerequisites 52 | - [Rust 1.88+](https://rustup.rs/) 53 | - [MailPace Account](https://mailpace.com/signup) with API token 54 | 55 | ### Installation & Setup 56 | 57 | 1. **Clone the repository**: 58 | ```bash 59 | git clone https://github.com/mailpace/vibe-smtp.git 60 | cd vibe-smtp 61 | ``` 62 | 63 | 2. **Build and run**: 64 | ```bash 65 | cargo run 66 | ``` 67 | 68 | 3. **Test the connection**: 69 | ```bash 70 | python3 test_smtp.py 71 | ``` 72 | 73 | ### Docker Deployment 74 | 75 | For comprehensive Docker setup with multi-port support, see **[DOCKER.md](DOCKER.md)**. 76 | 77 | #### Quick Start (Multi-Port Configuration) 78 | ```bash 79 | # Clone and build 80 | git clone https://github.com/mailpace/vibe-smtp.git 81 | cd vibe-smtp 82 | 83 | # Run with all SMTP ports using the helper script 84 | ./docker-run.sh multi-port --token your_api_token 85 | 86 | # Or use Docker Compose 87 | docker-compose up -d 88 | ``` 89 | 90 | #### Port Configuration 91 | The Docker setup supports industry-standard SMTP ports: 92 | 93 | | Port | Protocol | Description | TLS Support | 94 | |------|----------|-------------|-------------| 95 | | **25** | SMTP | Standard mail transfer | STARTTLS optional | 96 | | **587** | Submission | Message submission | STARTTLS optional | 97 | | **2525** | Alternative | Development/testing | STARTTLS optional | 98 | | **465** | SMTPS | SMTP over SSL | Implicit TLS (no STARTTLS) | 99 | 100 | #### Docker Run Options 101 | 102 | **Option 1: Helper Script (Recommended)** 103 | ```bash 104 | # Multi-port mode with all SMTP ports 105 | ./docker-run.sh multi-port --token your_api_token 106 | 107 | # Single-port mode (port 2525 only) 108 | ./docker-run.sh single-port --token your_api_token 109 | 110 | # Development mode with debug logging 111 | ./docker-run.sh multi-port --dev --token your_api_token 112 | ``` 113 | 114 | **Option 2: Docker Compose** 115 | ```bash 116 | # Copy environment file and edit with your API token 117 | cp .env.example .env 118 | # Edit .env and set MAILPACE_API_TOKEN=your_token 119 | 120 | # Start all services 121 | docker-compose up -d 122 | 123 | # View logs 124 | docker-compose logs -f vibe-gateway 125 | ``` 126 | 127 | **Option 3: Direct Docker Commands** 128 | ```bash 129 | # Build the image 130 | docker build -t vibe-gateway . 131 | 132 | # Run multi-port mode 133 | docker run -p 25:25 -p 587:587 -p 2525:2525 -p 465:465 \ 134 | -e MAILPACE_API_TOKEN=your_token \ 135 | vibe-gateway --docker-multi-port 136 | 137 | # Run single-port mode 138 | docker run -p 2525:2525 \ 139 | -e MAILPACE_API_TOKEN=your_token \ 140 | vibe-gateway --listen 0.0.0.0:2525 --enable-tls 141 | ``` 142 | 143 | #### TLS Certificate Management 144 | 145 | The Docker image includes test certificates for development. For production: 146 | 147 | **Option 1: Mount your own certificates** 148 | ```bash 149 | docker run -p 25:25 -p 587:587 -p 2525:2525 -p 465:465 \ 150 | -v /path/to/your/cert.pem:/app/test_cert.pem:ro \ 151 | -v /path/to/your/key.pem:/app/test_key.pem:ro \ 152 | -e MAILPACE_API_TOKEN=your_token \ 153 | vibe-gateway --docker-multi-port 154 | ``` 155 | 156 | **Option 2: Use Docker Compose with custom certificates** 157 | ```yaml 158 | # docker-compose.override.yml 159 | services: 160 | vibe-gateway: 161 | volumes: 162 | - ./your_cert.pem:/app/test_cert.pem:ro 163 | - ./your_key.pem:/app/test_key.pem:ro 164 | ``` 165 | 166 | #### Production Deployment Considerations 167 | 168 | 1. **Security**: Use proper TLS certificates in production 169 | 2. **Firewall**: Only expose necessary ports (typically 587 and 465) 170 | 3. **Monitoring**: Use the built-in health check endpoint 171 | 4. **Backup**: Ensure your MailPace API token is securely stored 172 | 5. **Scaling**: Run multiple containers behind a load balancer if needed 173 | 174 | #### Health Checks 175 | 176 | The Docker image includes health checks that verify SMTP connectivity: 177 | ```bash 178 | # Check container health 179 | docker inspect --format='{{.State.Health.Status}}' container_name 180 | 181 | # Manual health check 182 | docker exec container_name timeout 5 bash -c ' 💡 **Note**: Each domain has a unique API token for security and isolation. 218 | 219 | ## Quick Start 220 | 221 | 1. **Option 1: Users authenticate with their tokens** (recommended): 222 | ```bash 223 | cargo run 224 | ``` 225 | 226 | 2. **Option 2: Use a default token**: 227 | ```bash 228 | export MAILPACE_API_TOKEN=your_default_token_here 229 | cargo run 230 | ``` 231 | 232 | 3. Test with the included Python script: 233 | ```bash 234 | python3 test_smtp.py 235 | ``` 236 | 237 | ## 📧 SMTP Client Configuration 238 | 239 | Configure your email client or application with these settings: 240 | 241 | ### Standard Configuration 242 | 243 | | Setting | Value | Notes | 244 | |---------|-------|-------| 245 | | **SMTP Server** | `localhost` | Or your server's IP address | 246 | | **SMTP Port** | `25`, `587`, `2525`, or `465` | See port details below | 247 | | **Encryption** | Varies by port | See TLS configuration below | 248 | | **Authentication** | PLAIN or LOGIN | Standard SMTP AUTH methods | 249 | | **Username** | Your MailPace API token | Get from MailPace Dashboard | 250 | | **Password** | Your MailPace API token | Same as username | 251 | 252 | ### Port-Specific Configuration 253 | 254 | | Port | Purpose | TLS Mode | Encryption | Typical Use | 255 | |------|---------|----------|------------|-------------| 256 | | **25** | Standard SMTP | STARTTLS optional | None/STARTTLS | Mail transfer agents | 257 | | **587** | Message Submission | STARTTLS optional | None/STARTTLS | Email clients (recommended) | 258 | | **2525** | Alternative SMTP | STARTTLS optional | None/STARTTLS | Development/testing | 259 | | **465** | SMTP over SSL | Implicit TLS | SSL/TLS required | Legacy email clients | 260 | 261 | > 💡 **Recommendation**: Use port **587** for email clients as it's the modern standard for message submission. 262 | 263 | ### Popular Email Clients 264 | 265 |
266 | Postfix Configuration 267 | 268 | ```bash 269 | # /etc/postfix/main.cf 270 | # Use port 587 for message submission (recommended) 271 | relayhost = [localhost]:587 272 | smtp_sasl_auth_enable = yes 273 | smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd 274 | smtp_sasl_security_options = noanonymous 275 | smtp_tls_security_level = may 276 | 277 | # /etc/postfix/sasl_passwd 278 | [localhost]:587 your_api_token:your_api_token 279 | 280 | # Alternative: Use port 25 for standard SMTP 281 | # relayhost = [localhost]:25 282 | ``` 283 |
284 | 285 |
286 | Nodemailer (Node.js) 287 | 288 | ```javascript 289 | // Recommended: Port 587 with STARTTLS 290 | const transporter = nodemailer.createTransporter({ 291 | host: 'localhost', 292 | port: 587, 293 | secure: false, // true for 465, false for other ports 294 | auth: { 295 | user: 'your_api_token', 296 | pass: 'your_api_token' 297 | } 298 | }); 299 | 300 | // Alternative: Port 465 with implicit TLS 301 | const secureTransporter = nodemailer.createTransporter({ 302 | host: 'localhost', 303 | port: 465, 304 | secure: true, // implicit TLS 305 | auth: { 306 | user: 'your_api_token', 307 | pass: 'your_api_token' 308 | } 309 | }); 310 | ``` 311 |
312 | 313 | ## Usage 314 | 315 | 1. **Primary usage** (users provide their own API tokens): 316 | ```bash 317 | cargo run 318 | ``` 319 | 320 | 2. **With default token fallback** (optional): 321 | ```bash 322 | export MAILPACE_API_TOKEN=your_default_token_here 323 | cargo run 324 | ``` 325 | 326 | 3. **With custom settings**: 327 | ```bash 328 | cargo run -- --listen 0.0.0.0:587 --debug 329 | ``` 330 | 331 | ## How It Works 332 | 333 | When a user connects via SMTP: 334 | 1. They authenticate using their MailPace API token as both username and password 335 | 2. The server extracts this token from the SMTP AUTH command 336 | 3. The server uses this token to authenticate with the MailPace API 337 | 4. If no token is provided via SMTP AUTH, the server falls back to a default token (if configured) 338 | 339 | ## MailPace Features 340 | 341 | The server supports the following MailPace-specific features: 342 | 343 | ### Tags 344 | Add tags to emails by including the `X-MailPace-Tags` header: 345 | ``` 346 | X-MailPace-Tags: tag1, tag2, tag3 347 | ``` 348 | 349 | ### List-Unsubscribe 350 | Add unsubscribe links by including the `X-List-Unsubscribe` header: 351 | ``` 352 | X-List-Unsubscribe: , 353 | ``` 354 | 355 | ### Attachments 356 | Standard MIME attachments are automatically converted to MailPace format with base64 encoding. 357 | 358 | ## Attachment Support 359 | 360 | The server supports email attachments when enabled with the `--enable-attachments` flag: 361 | 362 | ```bash 363 | cargo run -- --enable-attachments 364 | ``` 365 | 366 | ### Attachment Configuration 367 | 368 | - `--enable-attachments`: Enable attachment parsing and forwarding 369 | - `--max-attachment-size`: Maximum size per attachment in bytes (default: 10MB) 370 | - `--max-attachments`: Maximum number of attachments per email (default: 10) 371 | 372 | ### Attachment Handling 373 | 374 | When attachment support is enabled, the server: 375 | - Parses MIME multipart messages 376 | - Extracts attachments with their filenames and content types 377 | - Converts attachments to base64 format for MailPace API 378 | - Validates attachment sizes and counts against configured limits 379 | - Logs attachment processing for debugging 380 | 381 | ### Example Usage 382 | 383 | ```bash 384 | # Enable attachments with custom limits 385 | cargo run -- --enable-attachments --max-attachment-size 5242880 --max-attachments 5 386 | 387 | # Test with the attachment test script 388 | python3 test_attachment.py 389 | ``` 390 | 391 | ## HTML Compression 392 | 393 | The server supports HTML compression for email bodies to reduce bandwidth and improve delivery performance: 394 | 395 | ```bash 396 | cargo run -- --enable-html-compression 397 | ``` 398 | 399 | ### HTML Compression Features 400 | 401 | - **Automatic Detection**: Only compresses content that appears to be HTML 402 | - **Safe Compression**: Preserves email client compatibility by keeping essential tags 403 | - **Comment Removal**: Strips HTML comments to reduce size 404 | - **Whitespace Optimization**: Removes unnecessary whitespace while preserving content 405 | - **CSS/JS Minification**: Minifies inline CSS and JavaScript 406 | - **Fallback Handling**: Uses original content if compression fails 407 | 408 | ### Compression Configuration 409 | 410 | - `--enable-html-compression`: Enable HTML compression for email bodies 411 | 412 | ### How It Works 413 | 414 | When HTML compression is enabled, the server: 415 | 1. Detects HTML content using heuristics (looks for common HTML tags) 416 | 2. Applies safe compression settings optimized for email clients 417 | 3. Removes comments and unnecessary whitespace 418 | 4. Minifies inline CSS and JavaScript 419 | 5. Logs compression statistics for monitoring 420 | 6. Falls back to original content if compression fails 421 | 422 | ### Example Usage 423 | 424 | ```bash 425 | # Enable both attachments and HTML compression 426 | cargo run -- --enable-attachments --enable-html-compression 427 | 428 | # Or with TLS support 429 | cargo run -- --enable-tls --enable-html-compression 430 | ``` 431 | 432 | ### Attachment Test 433 | 434 | The included `test_attachment.py` script demonstrates sending an email with an attachment: 435 | 436 | ```bash 437 | python3 test_attachment.py 438 | ``` 439 | 440 | This script creates a test email with: 441 | - Plain text body 442 | - A sample text file attachment 443 | - Proper MIME encoding 444 | 445 | ## Error Handling 446 | 447 | The server provides detailed error messages back to SMTP clients: 448 | - Authentication errors 449 | - API token validation 450 | - MailPace API errors 451 | - Email parsing errors 452 | 453 | ## 🛠️ Development 454 | 455 | ### Prerequisites 456 | - [Rust 1.88+](https://rustup.rs/) with Cargo 457 | - [Git](https://git-scm.com/) 458 | - [Docker](https://docker.com/) (optional) 459 | 460 | ### Local Development Setup 461 | 462 | 1. **Clone and setup**: 463 | ```bash 464 | git clone https://github.com/mailpace/vibe-smtp.git 465 | cd vibe-smtp 466 | ``` 467 | 468 | 2. **Build and run**: 469 | ```bash 470 | cargo build 471 | cargo run 472 | ``` 473 | 474 | 3. **Development with auto-reload**: 475 | ```bash 476 | cargo install cargo-watch 477 | cargo watch -x run 478 | ``` 479 | 480 | 4. **Debug mode with detailed logging**: 481 | ```bash 482 | cargo run -- --debug 483 | ``` 484 | 485 | ### 🏗️ Project Structure 486 | 487 | ``` 488 | src/ 489 | ├── main.rs # Application entry point 490 | ├── lib.rs # Library exports 491 | ├── cli.rs # Command-line interface 492 | ├── smtp.rs # SMTP server implementation 493 | ├── mailpace.rs # MailPace API integration 494 | ├── connection.rs # Connection handling 495 | ├── compression.rs # HTML compression 496 | ├── mime.rs # MIME parsing 497 | └── tls.rs # TLS/encryption support 498 | ``` 499 | 500 | ### 🧪 Code Quality 501 | 502 | ```bash 503 | # Format code 504 | cargo fmt 505 | 506 | # Lint code 507 | cargo clippy 508 | 509 | # Security audit 510 | cargo audit 511 | 512 | # Documentation 513 | cargo doc --open 514 | ``` 515 | 516 | ### 📦 Key Dependencies 517 | 518 | | Crate | Purpose | Version | 519 | |-------|---------|---------| 520 | | [`tokio`](https://tokio.rs/) | Async runtime and networking | Latest | 521 | | [`reqwest`](https://docs.rs/reqwest/) | HTTP client for MailPace API | Latest | 522 | | [`mail-parser`](https://docs.rs/mail-parser/) | RFC-compliant email parsing | Latest | 523 | | [`serde`](https://serde.rs/) | JSON serialization framework | Latest | 524 | | [`base64`](https://docs.rs/base64/) | Attachment encoding | Latest | 525 | | [`tracing`](https://docs.rs/tracing/) | Structured logging | Latest | 526 | | [`clap`](https://docs.rs/clap/) | Command-line argument parsing | Latest | 527 | 528 | ### 🤝 Contributing 529 | 530 | We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. 531 | 532 | 1. Fork the repository 533 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 534 | 3. Commit your changes (`git commit -m 'Add amazing feature'`) 535 | 4. Push to the branch (`git push origin feature/amazing-feature`) 536 | 5. Open a Pull Request 537 | 538 | ## 📋 License 539 | 540 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 541 | 542 | --- 543 | 544 |
545 | 546 | ## About MailPace 547 | 548 | **Vibe Gateway** is proudly developed by [MailPace](https://mailpace.com) - the developer-friendly email delivery service. 549 | 550 | [![MailPace](https://img.shields.io/badge/Powered%20by-MailPace-blue?style=for-the-badge)](https://mailpace.com) 551 | 552 | ### Why Choose MailPace? 553 | 554 | - 🚀 **99.9% Uptime SLA** - Enterprise-grade reliability 555 | - 💰 **Transparent Pricing** - No hidden fees or overages 556 | - 🛡️ **Privacy-First** - GDPR compliant with EU data residency 557 | - 📊 **Real-time Analytics** - Advanced delivery insights 558 | - 🤝 **Developer-Friendly** - Comprehensive APIs and documentation 559 | 560 | ### Connect With Us 561 | 562 | [🌐 Website](https://mailpace.com) • [📚 Documentation](https://docs.mailpace.com) • [💬 Discord](https://discord.gg/mailpace) • [🐦 Twitter](https://twitter.com/mailpace) • [📧 Support](mailto:support@mailpace.com) 563 | 564 | --- 565 | 566 | **Built with ❤️ by the MailPace team** 567 | 568 |
569 | 570 | ## 🧪 Testing 571 | 572 | [![Test Coverage](https://img.shields.io/badge/coverage-95%25-brightgreen.svg)](https://github.com/mailpace/vibe-smtp/actions) 573 | [![Integration Tests](https://img.shields.io/badge/integration-passing-green.svg)](https://github.com/mailpace/vibe-smtp/actions) 574 | [![Performance Tests](https://img.shields.io/badge/performance-optimized-blue.svg)](https://github.com/mailpace/vibe-smtp/actions) 575 | 576 | This project includes a comprehensive test suite to ensure reliability and performance: 577 | 578 | ### 🎯 Test Suite Overview 579 | - **Integration Tests**: End-to-end SMTP functionality with mock MailPace API 580 | - **Unit Tests**: Individual component testing with 95%+ coverage 581 | - **Performance Tests**: Load testing and throughput benchmarking 582 | - **Security Tests**: Authentication and input validation 583 | - **CI/CD Pipeline**: Automated testing on every commit 584 | 585 | ### 🏃‍♂️ Running Tests 586 | 587 | #### Quick Start 588 | ```bash 589 | # Run all tests with coverage 590 | ./test.sh 591 | 592 | # Run specific test suites 593 | ./test.sh integration # Integration tests only 594 | ./test.sh unit # Unit tests only 595 | ./test.sh performance # Performance tests only 596 | ./test.sh coverage # Generate coverage report 597 | ``` 598 | 599 | #### Manual Test Commands 600 | ```bash 601 | # All tests 602 | cargo test 603 | 604 | # Integration tests with mock MailPace API 605 | cargo test --test integration_tests 606 | 607 | # Unit tests for individual components 608 | cargo test --test mailpace_tests 609 | 610 | # Performance and load tests 611 | cargo test --test performance_tests --release 612 | ``` 613 | 614 | ### 📊 Test Coverage 615 | - **SMTP Protocol**: Command handling, authentication, data transfer 616 | - **MailPace Integration**: API calls, error handling, payload formatting 617 | - **Email Processing**: Attachments, HTML/text content, headers 618 | - **Performance**: Concurrent connections, throughput, resource usage 619 | - **Security**: Authentication, input validation, error handling 620 | 621 | ### 🔄 Continuous Integration 622 | The project uses GitHub Actions for automated testing: 623 | - ✅ **Code Quality**: Formatting, linting, and security audits 624 | - ✅ **Cross-Platform**: Testing on Linux, macOS, and Windows 625 | - ✅ **Performance**: Automated benchmarking and regression detection 626 | - ✅ **Security**: Dependency vulnerability scanning 627 | - ✅ **Docker**: Container build verification and security scanning 628 | 629 | For detailed testing documentation, see [TESTING.md](TESTING.md). 630 | -------------------------------------------------------------------------------- /src/mime.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use base64::{engine::general_purpose, Engine as _}; 3 | use std::collections::HashMap; 4 | use tracing::{debug, warn}; 5 | 6 | use crate::mailpace::Attachment; 7 | 8 | #[derive(Debug, Clone)] 9 | pub struct MimeHeader { 10 | pub name: String, 11 | pub value: String, 12 | pub params: HashMap, 13 | } 14 | 15 | impl MimeHeader { 16 | pub fn parse(line: &str) -> Result { 17 | let mut parts = line.splitn(2, ':'); 18 | let name = parts.next().unwrap_or("").trim().to_lowercase(); 19 | let value = parts.next().unwrap_or("").trim(); 20 | 21 | // Parse parameters from value (e.g., "text/plain; charset=utf-8") 22 | let mut params = HashMap::new(); 23 | let mut value_parts = value.split(';'); 24 | let main_value = value_parts.next().unwrap_or("").trim().to_string(); 25 | 26 | for param in value_parts { 27 | let param = param.trim(); 28 | if let Some(eq_pos) = param.find('=') { 29 | let key = param[..eq_pos].trim().to_lowercase(); 30 | let mut val = param[eq_pos + 1..].trim(); 31 | 32 | // Remove quotes 33 | if val.starts_with('"') && val.ends_with('"') { 34 | val = &val[1..val.len() - 1]; 35 | } 36 | 37 | params.insert(key, val.to_string()); 38 | } 39 | } 40 | 41 | Ok(MimeHeader { 42 | name, 43 | value: main_value, 44 | params, 45 | }) 46 | } 47 | 48 | pub fn get_param(&self, name: &str) -> Option<&String> { 49 | self.params.get(name) 50 | } 51 | } 52 | 53 | #[derive(Debug)] 54 | pub struct MimePart { 55 | pub headers: HashMap, 56 | pub body: Vec, 57 | } 58 | 59 | impl Default for MimePart { 60 | fn default() -> Self { 61 | Self::new() 62 | } 63 | } 64 | 65 | impl MimePart { 66 | pub fn new() -> Self { 67 | Self { 68 | headers: HashMap::new(), 69 | body: Vec::new(), 70 | } 71 | } 72 | 73 | pub fn get_header(&self, name: &str) -> Option<&MimeHeader> { 74 | self.headers.get(&name.to_lowercase()) 75 | } 76 | 77 | pub fn is_attachment(&self) -> bool { 78 | if let Some(disposition) = self.get_header("content-disposition") { 79 | disposition.value.starts_with("attachment") 80 | } else { 81 | false 82 | } 83 | } 84 | 85 | pub fn get_filename(&self) -> Option { 86 | if let Some(disposition) = self.get_header("content-disposition") { 87 | if let Some(filename) = disposition.get_param("filename") { 88 | return Some(filename.clone()); 89 | } 90 | } 91 | 92 | if let Some(content_type) = self.get_header("content-type") { 93 | if let Some(name) = content_type.get_param("name") { 94 | return Some(name.clone()); 95 | } 96 | } 97 | 98 | None 99 | } 100 | 101 | pub fn get_content_type(&self) -> String { 102 | if let Some(ct) = self.get_header("content-type") { 103 | ct.value.clone() 104 | } else { 105 | "application/octet-stream".to_string() 106 | } 107 | } 108 | 109 | pub fn to_attachment(&self) -> Result { 110 | let filename = self 111 | .get_filename() 112 | .unwrap_or_else(|| "attachment".to_string()); 113 | 114 | let content_type = self.get_content_type(); 115 | 116 | // Check if content is already base64 encoded 117 | let encoding = self 118 | .get_header("content-transfer-encoding") 119 | .map(|h| h.value.to_lowercase()) 120 | .unwrap_or_else(|| "7bit".to_string()); 121 | 122 | let content = if encoding == "base64" { 123 | // Content is already base64 encoded, clean it up 124 | let content_str = String::from_utf8_lossy(&self.body); 125 | content_str 126 | .chars() 127 | .filter(|c| !c.is_whitespace()) 128 | .collect::() 129 | } else { 130 | // Content needs to be base64 encoded 131 | general_purpose::STANDARD.encode(&self.body) 132 | }; 133 | 134 | Ok(Attachment { 135 | name: filename, 136 | content, 137 | content_type, 138 | cid: None, // TODO: Handle Content-ID if needed 139 | }) 140 | } 141 | } 142 | 143 | pub struct MimeParser { 144 | max_attachment_size: usize, 145 | max_attachments: usize, 146 | } 147 | 148 | impl MimeParser { 149 | pub fn new(max_attachment_size: usize, max_attachments: usize) -> Self { 150 | Self { 151 | max_attachment_size, 152 | max_attachments, 153 | } 154 | } 155 | 156 | pub fn parse_email( 157 | &self, 158 | email_content: &str, 159 | ) -> Result<(HashMap, String, Vec)> { 160 | let mut headers = HashMap::new(); 161 | let mut body_lines = Vec::new(); 162 | let mut in_headers = true; 163 | 164 | // First pass: separate headers from body 165 | for line in email_content.lines() { 166 | if in_headers { 167 | if line.is_empty() { 168 | in_headers = false; 169 | continue; 170 | } 171 | 172 | if let Some(colon_pos) = line.find(':') { 173 | let key = line[..colon_pos].trim().to_lowercase(); 174 | let value = line[colon_pos + 1..].trim().to_string(); 175 | headers.insert(key, value); 176 | } 177 | } else { 178 | body_lines.push(line); 179 | } 180 | } 181 | 182 | let body_content = body_lines.join("\n"); 183 | 184 | // Check if this is a multipart message 185 | if let Some(content_type) = headers.get("content-type") { 186 | if content_type.starts_with("multipart/") { 187 | let boundary = self.extract_boundary(content_type)?; 188 | let (text_body, attachments) = self.parse_multipart(&body_content, &boundary)?; 189 | return Ok((headers, text_body, attachments)); 190 | } 191 | } 192 | 193 | // Single part message - no attachments 194 | Ok((headers, body_content, Vec::new())) 195 | } 196 | 197 | fn extract_boundary(&self, content_type: &str) -> Result { 198 | for part in content_type.split(';') { 199 | let part = part.trim(); 200 | if part.to_lowercase().starts_with("boundary") { 201 | if let Some(eq_pos) = part.find('=') { 202 | let boundary = part[eq_pos + 1..].trim(); 203 | return Ok(boundary.trim_matches('"').to_string()); 204 | } 205 | } 206 | } 207 | Err(anyhow::anyhow!("No boundary found in Content-Type")) 208 | } 209 | 210 | fn parse_multipart(&self, body: &str, boundary: &str) -> Result<(String, Vec)> { 211 | let boundary_start = format!("--{boundary}"); 212 | let boundary_end = format!("--{boundary}--"); 213 | 214 | let mut parts = Vec::new(); 215 | let mut current_part = Vec::new(); 216 | let mut in_part = false; 217 | 218 | for line in body.lines() { 219 | if line == boundary_start { 220 | if in_part && !current_part.is_empty() { 221 | parts.push(current_part.join("\n")); 222 | current_part.clear(); 223 | } 224 | in_part = true; 225 | } else if line == boundary_end { 226 | if in_part && !current_part.is_empty() { 227 | parts.push(current_part.join("\n")); 228 | } 229 | break; 230 | } else if in_part { 231 | current_part.push(line); 232 | } 233 | } 234 | 235 | let mut text_body = String::new(); 236 | let mut attachments = Vec::new(); 237 | 238 | for part_content in parts { 239 | let mime_part = self.parse_mime_part(&part_content)?; 240 | 241 | if mime_part.is_attachment() { 242 | if attachments.len() >= self.max_attachments { 243 | warn!( 244 | "Maximum number of attachments ({}) exceeded, skipping", 245 | self.max_attachments 246 | ); 247 | continue; 248 | } 249 | 250 | if mime_part.body.len() > self.max_attachment_size { 251 | warn!( 252 | "Attachment too large ({} bytes), skipping", 253 | mime_part.body.len() 254 | ); 255 | continue; 256 | } 257 | 258 | match mime_part.to_attachment() { 259 | Ok(attachment) => { 260 | debug!("Found attachment: {}", attachment.name); 261 | attachments.push(attachment); 262 | } 263 | Err(e) => { 264 | warn!("Failed to process attachment: {}", e); 265 | } 266 | } 267 | } else { 268 | // This is likely the text body 269 | let content_type = mime_part.get_content_type(); 270 | if content_type.starts_with("text/") { 271 | let body_text = String::from_utf8_lossy(&mime_part.body); 272 | if !text_body.is_empty() { 273 | text_body.push('\n'); 274 | } 275 | text_body.push_str(&body_text); 276 | } 277 | } 278 | } 279 | 280 | Ok((text_body, attachments)) 281 | } 282 | 283 | fn parse_mime_part(&self, part_content: &str) -> Result { 284 | let mut part = MimePart::new(); 285 | let mut body_lines = Vec::new(); 286 | let mut in_headers = true; 287 | let mut found_header = false; 288 | 289 | for line in part_content.lines() { 290 | if in_headers { 291 | if line.is_empty() { 292 | in_headers = false; 293 | continue; 294 | } 295 | 296 | if line.contains(':') { 297 | if let Ok(header) = MimeHeader::parse(line) { 298 | part.headers.insert(header.name.clone(), header); 299 | found_header = true; 300 | } 301 | } else if !found_header { 302 | // If we haven't found any headers yet and this line doesn't contain ':', 303 | // treat this as body content (no headers at all) 304 | in_headers = false; 305 | body_lines.push(line); 306 | } 307 | } else { 308 | body_lines.push(line); 309 | } 310 | } 311 | 312 | part.body = body_lines.join("\n").into_bytes(); 313 | Ok(part) 314 | } 315 | } 316 | 317 | #[cfg(test)] 318 | mod tests { 319 | use super::*; 320 | 321 | #[test] 322 | fn test_mime_header_parse_simple() { 323 | let header = MimeHeader::parse("Content-Type: text/plain").unwrap(); 324 | assert_eq!(header.name, "content-type"); 325 | assert_eq!(header.value, "text/plain"); 326 | assert_eq!(header.params.len(), 0); 327 | } 328 | 329 | #[test] 330 | fn test_mime_header_parse_with_params() { 331 | let header = 332 | MimeHeader::parse("Content-Type: text/plain; charset=utf-8; boundary=\"test123\"") 333 | .unwrap(); 334 | assert_eq!(header.name, "content-type"); 335 | assert_eq!(header.value, "text/plain"); 336 | assert_eq!(header.params.get("charset"), Some(&"utf-8".to_string())); 337 | assert_eq!(header.params.get("boundary"), Some(&"test123".to_string())); 338 | } 339 | 340 | #[test] 341 | fn test_mime_header_parse_no_value() { 342 | let header = MimeHeader::parse("Content-Type:").unwrap(); 343 | assert_eq!(header.name, "content-type"); 344 | assert_eq!(header.value, ""); 345 | assert_eq!(header.params.len(), 0); 346 | } 347 | 348 | #[test] 349 | fn test_mime_header_parse_no_colon() { 350 | let header = MimeHeader::parse("InvalidHeader").unwrap(); 351 | assert_eq!(header.name, "invalidheader"); 352 | assert_eq!(header.value, ""); 353 | } 354 | 355 | #[test] 356 | fn test_mime_header_get_param() { 357 | let header = MimeHeader::parse("Content-Type: text/plain; charset=utf-8").unwrap(); 358 | assert_eq!(header.get_param("charset"), Some(&"utf-8".to_string())); 359 | assert_eq!(header.get_param("nonexistent"), None); 360 | } 361 | 362 | #[test] 363 | fn test_mime_part_new() { 364 | let part = MimePart::new(); 365 | assert_eq!(part.headers.len(), 0); 366 | assert_eq!(part.body.len(), 0); 367 | } 368 | 369 | #[test] 370 | fn test_mime_part_default() { 371 | let part = MimePart::default(); 372 | assert_eq!(part.headers.len(), 0); 373 | assert_eq!(part.body.len(), 0); 374 | } 375 | 376 | #[test] 377 | fn test_mime_part_get_header() { 378 | let mut part = MimePart::new(); 379 | let header = MimeHeader::parse("Content-Type: text/plain").unwrap(); 380 | part.headers.insert("content-type".to_string(), header); 381 | 382 | assert!(part.get_header("Content-Type").is_some()); 383 | assert!(part.get_header("content-type").is_some()); 384 | assert!(part.get_header("nonexistent").is_none()); 385 | } 386 | 387 | #[test] 388 | fn test_mime_part_is_attachment_true() { 389 | let mut part = MimePart::new(); 390 | let header = 391 | MimeHeader::parse("Content-Disposition: attachment; filename=\"test.txt\"").unwrap(); 392 | part.headers 393 | .insert("content-disposition".to_string(), header); 394 | 395 | assert!(part.is_attachment()); 396 | } 397 | 398 | #[test] 399 | fn test_mime_part_is_attachment_false() { 400 | let mut part = MimePart::new(); 401 | let header = MimeHeader::parse("Content-Disposition: inline").unwrap(); 402 | part.headers 403 | .insert("content-disposition".to_string(), header); 404 | 405 | assert!(!part.is_attachment()); 406 | } 407 | 408 | #[test] 409 | fn test_mime_part_is_attachment_no_header() { 410 | let part = MimePart::new(); 411 | assert!(!part.is_attachment()); 412 | } 413 | 414 | #[test] 415 | fn test_mime_part_get_filename_from_disposition() { 416 | let mut part = MimePart::new(); 417 | let header = 418 | MimeHeader::parse("Content-Disposition: attachment; filename=\"test.txt\"").unwrap(); 419 | part.headers 420 | .insert("content-disposition".to_string(), header); 421 | 422 | assert_eq!(part.get_filename(), Some("test.txt".to_string())); 423 | } 424 | 425 | #[test] 426 | fn test_mime_part_get_filename_from_content_type() { 427 | let mut part = MimePart::new(); 428 | let header = MimeHeader::parse("Content-Type: text/plain; name=\"test.txt\"").unwrap(); 429 | part.headers.insert("content-type".to_string(), header); 430 | 431 | assert_eq!(part.get_filename(), Some("test.txt".to_string())); 432 | } 433 | 434 | #[test] 435 | fn test_mime_part_get_filename_none() { 436 | let part = MimePart::new(); 437 | assert_eq!(part.get_filename(), None); 438 | } 439 | 440 | #[test] 441 | fn test_mime_part_get_content_type_default() { 442 | let part = MimePart::new(); 443 | assert_eq!(part.get_content_type(), "application/octet-stream"); 444 | } 445 | 446 | #[test] 447 | fn test_mime_part_get_content_type_custom() { 448 | let mut part = MimePart::new(); 449 | let header = MimeHeader::parse("Content-Type: text/plain").unwrap(); 450 | part.headers.insert("content-type".to_string(), header); 451 | 452 | assert_eq!(part.get_content_type(), "text/plain"); 453 | } 454 | 455 | #[test] 456 | fn test_mime_part_to_attachment_base64() { 457 | let mut part = MimePart::new(); 458 | 459 | let disposition_header = 460 | MimeHeader::parse("Content-Disposition: attachment; filename=\"test.txt\"").unwrap(); 461 | part.headers 462 | .insert("content-disposition".to_string(), disposition_header); 463 | 464 | let content_type_header = MimeHeader::parse("Content-Type: text/plain").unwrap(); 465 | part.headers 466 | .insert("content-type".to_string(), content_type_header); 467 | 468 | let encoding_header = MimeHeader::parse("Content-Transfer-Encoding: base64").unwrap(); 469 | part.headers 470 | .insert("content-transfer-encoding".to_string(), encoding_header); 471 | 472 | part.body = "SGVsbG8gV29ybGQ=".as_bytes().to_vec(); // "Hello World" in base64 473 | 474 | let attachment = part.to_attachment().unwrap(); 475 | assert_eq!(attachment.name, "test.txt"); 476 | assert_eq!(attachment.content_type, "text/plain"); 477 | assert_eq!(attachment.content, "SGVsbG8gV29ybGQ="); 478 | assert_eq!(attachment.cid, None); 479 | } 480 | 481 | #[test] 482 | fn test_mime_part_to_attachment_plain() { 483 | let mut part = MimePart::new(); 484 | 485 | let disposition_header = 486 | MimeHeader::parse("Content-Disposition: attachment; filename=\"test.txt\"").unwrap(); 487 | part.headers 488 | .insert("content-disposition".to_string(), disposition_header); 489 | 490 | let content_type_header = MimeHeader::parse("Content-Type: text/plain").unwrap(); 491 | part.headers 492 | .insert("content-type".to_string(), content_type_header); 493 | 494 | part.body = "Hello World".as_bytes().to_vec(); 495 | 496 | let attachment = part.to_attachment().unwrap(); 497 | assert_eq!(attachment.name, "test.txt"); 498 | assert_eq!(attachment.content_type, "text/plain"); 499 | assert_eq!( 500 | attachment.content, 501 | general_purpose::STANDARD.encode("Hello World") 502 | ); 503 | assert_eq!(attachment.cid, None); 504 | } 505 | 506 | #[test] 507 | fn test_mime_part_to_attachment_no_filename() { 508 | let mut part = MimePart::new(); 509 | 510 | let content_type_header = MimeHeader::parse("Content-Type: text/plain").unwrap(); 511 | part.headers 512 | .insert("content-type".to_string(), content_type_header); 513 | 514 | part.body = "Hello World".as_bytes().to_vec(); 515 | 516 | let attachment = part.to_attachment().unwrap(); 517 | assert_eq!(attachment.name, "attachment"); 518 | assert_eq!(attachment.content_type, "text/plain"); 519 | } 520 | 521 | #[test] 522 | fn test_mime_parser_new() { 523 | let parser = MimeParser::new(1024, 10); 524 | assert_eq!(parser.max_attachment_size, 1024); 525 | assert_eq!(parser.max_attachments, 10); 526 | } 527 | 528 | #[test] 529 | fn test_mime_parser_parse_simple_email() { 530 | let parser = MimeParser::new(1024, 10); 531 | let email = "From: test@example.com\nTo: user@example.com\nSubject: Test\n\nHello World"; 532 | 533 | let (headers, body, attachments) = parser.parse_email(email).unwrap(); 534 | 535 | assert_eq!(headers.get("from"), Some(&"test@example.com".to_string())); 536 | assert_eq!(headers.get("to"), Some(&"user@example.com".to_string())); 537 | assert_eq!(headers.get("subject"), Some(&"Test".to_string())); 538 | assert_eq!(body, "Hello World"); 539 | assert_eq!(attachments.len(), 0); 540 | } 541 | 542 | #[test] 543 | fn test_mime_parser_extract_boundary() { 544 | let parser = MimeParser::new(1024, 10); 545 | 546 | let content_type = "multipart/mixed; boundary=boundary123"; 547 | assert_eq!( 548 | parser.extract_boundary(content_type).unwrap(), 549 | "boundary123" 550 | ); 551 | 552 | let content_type_quoted = "multipart/mixed; boundary=\"boundary123\""; 553 | assert_eq!( 554 | parser.extract_boundary(content_type_quoted).unwrap(), 555 | "boundary123" 556 | ); 557 | } 558 | 559 | #[test] 560 | fn test_mime_parser_extract_boundary_error() { 561 | let parser = MimeParser::new(1024, 10); 562 | let content_type = "multipart/mixed"; 563 | assert!(parser.extract_boundary(content_type).is_err()); 564 | } 565 | 566 | #[test] 567 | fn test_mime_parser_parse_multipart_email() { 568 | let parser = MimeParser::new(1024, 10); 569 | let email = r#"From: test@example.com 570 | To: user@example.com 571 | Subject: Test with attachment 572 | Content-Type: multipart/mixed; boundary=boundary123 573 | 574 | --boundary123 575 | Content-Type: text/plain 576 | 577 | Hello World 578 | 579 | --boundary123 580 | Content-Type: text/plain 581 | Content-Disposition: attachment; filename="test.txt" 582 | 583 | File content here 584 | --boundary123--"#; 585 | 586 | let (headers, body, attachments) = parser.parse_email(email).unwrap(); 587 | 588 | assert_eq!(headers.get("from"), Some(&"test@example.com".to_string())); 589 | assert_eq!(body, "Hello World"); 590 | assert_eq!(attachments.len(), 1); 591 | assert_eq!(attachments[0].name, "test.txt"); 592 | } 593 | 594 | #[test] 595 | fn test_mime_parser_parse_multipart_multiple_text_parts() { 596 | let parser = MimeParser::new(1024, 10); 597 | let email = r#"Content-Type: multipart/mixed; boundary=boundary123 598 | 599 | --boundary123 600 | Content-Type: text/plain 601 | 602 | First part 603 | 604 | --boundary123 605 | Content-Type: text/html 606 | 607 | Second part 608 | --boundary123--"#; 609 | 610 | let (_, body, attachments) = parser.parse_email(email).unwrap(); 611 | 612 | assert!(body.contains("First part")); 613 | assert!(body.contains("Second part")); 614 | assert_eq!(attachments.len(), 0); 615 | } 616 | 617 | #[test] 618 | fn test_mime_parser_attachment_size_limit() { 619 | let parser = MimeParser::new(5, 10); // Very small size limit 620 | let email = r#"Content-Type: multipart/mixed; boundary=boundary123 621 | 622 | --boundary123 623 | Content-Type: text/plain 624 | Content-Disposition: attachment; filename="large.txt" 625 | 626 | This content is too large for the limit 627 | --boundary123--"#; 628 | 629 | let (_, _, attachments) = parser.parse_email(email).unwrap(); 630 | assert_eq!(attachments.len(), 0); // Should be skipped due to size 631 | } 632 | 633 | #[test] 634 | fn test_mime_parser_attachment_count_limit() { 635 | let parser = MimeParser::new(1024, 1); // Only 1 attachment allowed 636 | let email = r#"Content-Type: multipart/mixed; boundary=boundary123 637 | 638 | --boundary123 639 | Content-Type: text/plain 640 | Content-Disposition: attachment; filename="file1.txt" 641 | 642 | File 1 643 | 644 | --boundary123 645 | Content-Type: text/plain 646 | Content-Disposition: attachment; filename="file2.txt" 647 | 648 | File 2 649 | --boundary123--"#; 650 | 651 | let (_, _, attachments) = parser.parse_email(email).unwrap(); 652 | assert_eq!(attachments.len(), 1); // Only first attachment should be included 653 | } 654 | 655 | #[test] 656 | fn test_mime_parser_parse_mime_part() { 657 | let parser = MimeParser::new(1024, 10); 658 | let part_content = r#"Content-Type: text/plain; charset=utf-8 659 | Content-Disposition: attachment; filename="test.txt" 660 | 661 | This is the file content"#; 662 | 663 | let part = parser.parse_mime_part(part_content).unwrap(); 664 | 665 | assert!(part.get_header("content-type").is_some()); 666 | assert!(part.get_header("content-disposition").is_some()); 667 | assert_eq!( 668 | String::from_utf8_lossy(&part.body), 669 | "This is the file content" 670 | ); 671 | } 672 | 673 | #[test] 674 | fn test_mime_parser_parse_mime_part_no_headers() { 675 | let parser = MimeParser::new(1024, 10); 676 | let part_content = "Just content, no headers"; 677 | 678 | let part = parser.parse_mime_part(part_content).unwrap(); 679 | 680 | assert_eq!(part.headers.len(), 0); 681 | assert_eq!( 682 | String::from_utf8_lossy(&part.body), 683 | "Just content, no headers" 684 | ); 685 | } 686 | 687 | #[test] 688 | fn test_mime_parser_parse_mime_part_empty_line_separator() { 689 | let parser = MimeParser::new(1024, 10); 690 | let part_content = r#"Content-Type: text/plain 691 | 692 | Body content after empty line"#; 693 | 694 | let part = parser.parse_mime_part(part_content).unwrap(); 695 | 696 | assert!(part.get_header("content-type").is_some()); 697 | assert_eq!( 698 | String::from_utf8_lossy(&part.body), 699 | "Body content after empty line" 700 | ); 701 | } 702 | 703 | #[test] 704 | fn test_mime_header_parse_with_whitespace() { 705 | let header = 706 | MimeHeader::parse(" Content-Type : text/plain ; charset = utf-8 ").unwrap(); 707 | assert_eq!(header.name, "content-type"); 708 | assert_eq!(header.value, "text/plain"); 709 | assert_eq!(header.params.get("charset"), Some(&"utf-8".to_string())); 710 | } 711 | 712 | #[test] 713 | fn test_mime_part_base64_content_with_whitespace() { 714 | let mut part = MimePart::new(); 715 | 716 | let encoding_header = MimeHeader::parse("Content-Transfer-Encoding: base64").unwrap(); 717 | part.headers 718 | .insert("content-transfer-encoding".to_string(), encoding_header); 719 | 720 | // Base64 content with whitespace (newlines, spaces) 721 | part.body = "SGVs\nbG8g\r\n V29y\nbGQ=".as_bytes().to_vec(); 722 | 723 | let attachment = part.to_attachment().unwrap(); 724 | // Should clean up whitespace 725 | assert_eq!(attachment.content, "SGVsbG8gV29ybGQ="); 726 | } 727 | 728 | #[test] 729 | fn test_mime_parser_boundary_variations() { 730 | let parser = MimeParser::new(1024, 10); 731 | 732 | // Test with different boundary styles 733 | let boundary_tests = vec![ 734 | "multipart/mixed; boundary=simple", 735 | "multipart/mixed; boundary=\"quoted\"", 736 | "multipart/mixed; boundary = spaced", 737 | "multipart/mixed; charset=utf-8; boundary=after_other_param", 738 | ]; 739 | 740 | for content_type in boundary_tests { 741 | let result = parser.extract_boundary(content_type); 742 | assert!( 743 | result.is_ok(), 744 | "Failed to parse boundary from: {content_type}", 745 | ); 746 | } 747 | } 748 | } 749 | --------------------------------------------------------------------------------