├── .envrc ├── rustfmt.toml ├── .gitignore ├── statix.toml ├── public ├── favicon.ico ├── favicon.png ├── font │ ├── Inter-Black.woff2 │ ├── Inter-Bold.woff2 │ ├── Inter-Light.woff2 │ ├── Inter-Thin.woff2 │ ├── Inter-Italic.woff2 │ ├── Inter-Medium.woff2 │ ├── Inter-Regular.woff2 │ ├── InterVariable.woff2 │ ├── Inter-BoldItalic.woff2 │ ├── Inter-ExtraBold.woff2 │ ├── Inter-ExtraLight.woff2 │ ├── Inter-SemiBold.woff2 │ ├── Inter-ThinItalic.woff2 │ ├── Inter-BlackItalic.woff2 │ ├── Inter-LightItalic.woff2 │ ├── Inter-MediumItalic.woff2 │ ├── InterDisplay-Black.woff2 │ ├── InterDisplay-Bold.woff2 │ ├── InterDisplay-Light.woff2 │ ├── InterDisplay-Thin.woff2 │ ├── Inter-ExtraBoldItalic.woff2 │ ├── Inter-SemiBoldItalic.woff2 │ ├── InterDisplay-Italic.woff2 │ ├── InterDisplay-Medium.woff2 │ ├── InterDisplay-Regular.woff2 │ ├── InterDisplay-SemiBold.woff2 │ ├── InterVariable-Italic.woff2 │ ├── Inter-ExtraLightItalic.woff2 │ ├── InterDisplay-BoldItalic.woff2 │ ├── InterDisplay-ExtraBold.woff2 │ ├── InterDisplay-ExtraLight.woff2 │ ├── InterDisplay-ThinItalic.woff2 │ ├── InterDisplay-BlackItalic.woff2 │ ├── InterDisplay-LightItalic.woff2 │ ├── InterDisplay-MediumItalic.woff2 │ ├── InterDisplay-ExtraBoldItalic.woff2 │ ├── InterDisplay-SemiBoldItalic.woff2 │ ├── InterDisplay-ExtraLightItalic.woff2 │ └── inter.css └── logo.svg ├── rust-toolchain.toml ├── style └── tailwind.css ├── tailwind.config.js ├── nix ├── tests │ ├── lib.nix │ └── idmail.nix └── nixosModules │ └── idmail.nix ├── src ├── database.rs ├── state.rs ├── lib.rs ├── fileserv.rs ├── error_template.rs ├── main.rs ├── api.rs ├── provision.rs ├── auth.rs ├── utils.rs ├── domains.rs ├── app.rs └── mailboxes.rs ├── .github └── workflows │ ├── check-flake.yml │ └── run-nixos-tests.yml ├── LICENSE ├── migrations └── 20240423000000_create_schema.sql ├── Cargo.toml ├── flake.nix ├── flake.lock └── README.md /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 120 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .direnv 2 | result* 3 | .pre-commit-config.yaml 4 | /target/ 5 | pkg 6 | -------------------------------------------------------------------------------- /statix.toml: -------------------------------------------------------------------------------- 1 | disabled = [ 2 | "repeated_keys" 3 | "empty_list_concat" 4 | ] 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/favicon.png -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | targets = ["wasm32-unknown-unknown"] 4 | -------------------------------------------------------------------------------- /public/font/Inter-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-Black.woff2 -------------------------------------------------------------------------------- /public/font/Inter-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-Bold.woff2 -------------------------------------------------------------------------------- /public/font/Inter-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-Light.woff2 -------------------------------------------------------------------------------- /public/font/Inter-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-Thin.woff2 -------------------------------------------------------------------------------- /public/font/Inter-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-Italic.woff2 -------------------------------------------------------------------------------- /public/font/Inter-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-Medium.woff2 -------------------------------------------------------------------------------- /public/font/Inter-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-Regular.woff2 -------------------------------------------------------------------------------- /public/font/InterVariable.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterVariable.woff2 -------------------------------------------------------------------------------- /public/font/Inter-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-BoldItalic.woff2 -------------------------------------------------------------------------------- /public/font/Inter-ExtraBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-ExtraBold.woff2 -------------------------------------------------------------------------------- /public/font/Inter-ExtraLight.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-ExtraLight.woff2 -------------------------------------------------------------------------------- /public/font/Inter-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-SemiBold.woff2 -------------------------------------------------------------------------------- /public/font/Inter-ThinItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-ThinItalic.woff2 -------------------------------------------------------------------------------- /public/font/Inter-BlackItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-BlackItalic.woff2 -------------------------------------------------------------------------------- /public/font/Inter-LightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-LightItalic.woff2 -------------------------------------------------------------------------------- /public/font/Inter-MediumItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-MediumItalic.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-Black.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-Bold.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-Light.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-Thin.woff2 -------------------------------------------------------------------------------- /public/font/Inter-ExtraBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-ExtraBoldItalic.woff2 -------------------------------------------------------------------------------- /public/font/Inter-SemiBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-SemiBoldItalic.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-Italic.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-Medium.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-Regular.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-SemiBold.woff2 -------------------------------------------------------------------------------- /public/font/InterVariable-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterVariable-Italic.woff2 -------------------------------------------------------------------------------- /public/font/Inter-ExtraLightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/Inter-ExtraLightItalic.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-BoldItalic.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-ExtraBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-ExtraBold.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-ExtraLight.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-ExtraLight.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-ThinItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-ThinItalic.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-BlackItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-BlackItalic.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-LightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-LightItalic.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-MediumItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-MediumItalic.woff2 -------------------------------------------------------------------------------- /style/tailwind.css: -------------------------------------------------------------------------------- 1 | @import '../public/font/inter.css'; 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | -------------------------------------------------------------------------------- /public/font/InterDisplay-ExtraBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-ExtraBoldItalic.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-SemiBoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-SemiBoldItalic.woff2 -------------------------------------------------------------------------------- /public/font/InterDisplay-ExtraLightItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oddlama/idmail/HEAD/public/font/InterDisplay-ExtraLightItalic.woff2 -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const defaultTheme = require('tailwindcss/defaultTheme') 3 | 4 | module.exports = { 5 | content: { 6 | relative: true, 7 | files: ["*.html", "./src/**/*.rs"], 8 | }, 9 | darkMode: 'selector', 10 | theme: { 11 | extend: { 12 | fontFamily: { 13 | sans: ['InterVariable', ...defaultTheme.fontFamily.sans], 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } 19 | -------------------------------------------------------------------------------- /nix/tests/lib.nix: -------------------------------------------------------------------------------- 1 | test: 2 | { 3 | pkgs, 4 | lib, 5 | self, 6 | }: 7 | let 8 | nixos-lib = import (pkgs.path + "/nixos/lib") { }; 9 | testRunner = nixos-lib.runTest { 10 | hostPkgs = pkgs; 11 | # Skip evaluating the documentation to speed up the testing 12 | defaults.documentation.enable = lib.mkDefault false; 13 | # Allow access to our flake 14 | node.specialArgs = { 15 | inherit self; 16 | }; 17 | imports = [ test ]; 18 | }; 19 | in 20 | testRunner.config.result 21 | -------------------------------------------------------------------------------- /src/database.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "ssr")] 2 | pub mod ssr { 3 | use crate::auth::ssr::AuthSession; 4 | use leptos::{use_context, ServerFnError}; 5 | use sqlx::SqlitePool; 6 | 7 | pub fn pool() -> Result { 8 | use_context::().ok_or_else(|| ServerFnError::ServerError("Pool missing.".into())) 9 | } 10 | 11 | pub fn auth() -> Result { 12 | use_context::().ok_or_else(|| ServerFnError::ServerError("Auth session missing.".into())) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::FromRef; 2 | use leptos::LeptosOptions; 3 | use leptos_router::RouteListing; 4 | use sqlx::SqlitePool; 5 | 6 | /// This takes advantage of Axum's SubStates feature by deriving FromRef. This is the only way to have more than one 7 | /// item in Axum's State. Leptos requires you to have leptosOptions in your State struct for the leptos route handlers 8 | #[derive(FromRef, Debug, Clone)] 9 | pub struct AppState { 10 | pub leptos_options: LeptosOptions, 11 | pub pool: SqlitePool, 12 | pub routes: Vec, 13 | } 14 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod aliases; 2 | #[cfg(feature = "ssr")] 3 | pub mod api; 4 | pub mod app; 5 | pub mod auth; 6 | pub mod database; 7 | pub mod domains; 8 | pub mod error_template; 9 | #[cfg(feature = "ssr")] 10 | pub mod fileserv; 11 | pub mod mailboxes; 12 | #[cfg(feature = "ssr")] 13 | pub mod provision; 14 | #[cfg(feature = "ssr")] 15 | pub mod state; 16 | pub mod users; 17 | pub mod utils; 18 | 19 | #[cfg(feature = "hydrate")] 20 | #[wasm_bindgen::prelude::wasm_bindgen] 21 | pub fn hydrate() { 22 | use crate::app::App; 23 | _ = console_log::init_with_level(log::Level::Debug); 24 | console_error_panic_hook::set_once(); 25 | 26 | leptos::mount_to_body(App); 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/check-flake.yml: -------------------------------------------------------------------------------- 1 | name: Check Nix Flake 2 | on: 3 | push: 4 | pull_request: 5 | permissions: 6 | contents: read 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 9 | cancel-in-progress: true 10 | jobs: 11 | flake-check: 12 | name: Check Nix Flake 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | - name: Install Nix 18 | uses: DeterminateSystems/nix-installer-action@main 19 | - name: Setup Nix Cache 20 | uses: DeterminateSystems/flakehub-cache-action@main 21 | - name: Check Nix Flake 22 | shell: bash 23 | run: nix flake check 24 | -------------------------------------------------------------------------------- /.github/workflows/run-nixos-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run NixOS Tests 2 | on: 3 | push: 4 | pull_request: 5 | permissions: 6 | contents: read 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 9 | cancel-in-progress: true 10 | jobs: 11 | nixos-tests: 12 | name: Check Nix Flake 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | - name: Install Nix 18 | uses: DeterminateSystems/nix-installer-action@main 19 | - name: Setup Nix Cache 20 | uses: DeterminateSystems/magic-nix-cache-action@main 21 | - name: Check Nix Flake 22 | shell: bash 23 | run: nix build -L .#nixosTest 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 oddlama 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /src/fileserv.rs: -------------------------------------------------------------------------------- 1 | use crate::error_template::{AppError, ErrorTemplate}; 2 | use axum::{ 3 | body::Body, 4 | extract::State, 5 | http::{Request, Response, StatusCode, Uri}, 6 | response::{IntoResponse, Response as AxumResponse}, 7 | }; 8 | use leptos::{view, Errors, LeptosOptions}; 9 | use tower::ServiceExt; 10 | use tower_http::services::ServeDir; 11 | 12 | pub async fn file_and_error_handler( 13 | uri: Uri, 14 | State(options): State, 15 | req: Request, 16 | ) -> AxumResponse { 17 | let root = options.site_root.clone(); 18 | let res = get_static_file(uri.clone(), &root).await.unwrap(); 19 | 20 | if res.status() == StatusCode::OK { 21 | res.into_response() 22 | } else { 23 | let mut errors = Errors::default(); 24 | errors.insert_with_default_key(AppError::NotFound); 25 | let handler = leptos_axum::render_app_to_stream( 26 | options.to_owned(), 27 | move || view! { }, 28 | ); 29 | handler(req).await.into_response() 30 | } 31 | } 32 | 33 | async fn get_static_file(uri: Uri, root: &str) -> Result, (StatusCode, String)> { 34 | let req = Request::builder().uri(uri.clone()).body(Body::empty()).unwrap(); 35 | // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot` 36 | // This path is relative to the cargo root 37 | #[allow(unreachable_patterns)] 38 | match ServeDir::new(root).oneshot(req).await { 39 | Ok(res) => Ok(res.into_response()), 40 | Err(err) => Err(( 41 | StatusCode::INTERNAL_SERVER_ERROR, 42 | format!("Something went wrong: {err}"), 43 | )), 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/20240423000000_create_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users ( 2 | username TEXT NOT NULL PRIMARY KEY, 3 | password_hash TEXT NOT NULL, 4 | admin BOOLEAN NOT NULL DEFAULT FALSE, 5 | active BOOL NOT NULL DEFAULT TRUE, 6 | provisioned BOOL NOT NULL DEFAULT FALSE, 7 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 8 | ) WITHOUT ROWID; 9 | 10 | CREATE TABLE IF NOT EXISTS domains ( 11 | domain TEXT NOT NULL PRIMARY KEY, 12 | catch_all TEXT, 13 | public BOOL NOT NULL DEFAULT FALSE, 14 | active BOOL NOT NULL DEFAULT TRUE, 15 | owner TEXT NOT NULL, 16 | provisioned BOOL NOT NULL DEFAULT FALSE, 17 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 18 | -- FOREIGN KEY (owner) REFERENCES users (username) ON DELETE CASCADE 19 | -- FOREIGN KEY (catch_all) REFERENCES mailboxes (address) ON DELETE CASCADE 20 | ) WITHOUT ROWID; 21 | 22 | CREATE TABLE IF NOT EXISTS aliases ( 23 | address TEXT NOT NULL PRIMARY KEY, 24 | -- associated domain. Technically redundant but required to do efficient JOIN with the domain table. 25 | domain TEXT NOT NULL, 26 | target TEXT NOT NULL, 27 | comment TEXT NOT NULL, 28 | n_recv INTEGER NOT NULL DEFAULT 0, 29 | n_sent INTEGER NOT NULL DEFAULT 0, 30 | active BOOLEAN NOT NULL DEFAULT TRUE, 31 | owner TEXT NOT NULL, 32 | provisioned BOOL NOT NULL DEFAULT FALSE, 33 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 34 | -- FOREIGN KEY (target) REFERENCES mailboxes (address) ON DELETE CASCADE 35 | -- FOREIGN KEY (owner) REFERENCES users (username) ON DELETE CASCADE 36 | ) WITHOUT ROWID; 37 | 38 | CREATE TABLE IF NOT EXISTS mailboxes ( 39 | address TEXT NOT NULL PRIMARY KEY, 40 | -- associated domain. Technically redundant but required to do efficient JOIN with the domain table. 41 | domain TEXT NOT NULL, 42 | password_hash TEXT NOT NULL, 43 | api_token TEXT UNIQUE DEFAULT NULL, 44 | active BOOL NOT NULL DEFAULT TRUE, 45 | owner TEXT NOT NULL, 46 | provisioned BOOL NOT NULL DEFAULT FALSE, 47 | created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 48 | -- FOREIGN KEY (owner) REFERENCES users (username) ON DELETE CASCADE 49 | ) WITHOUT ROWID; 50 | -------------------------------------------------------------------------------- /src/error_template.rs: -------------------------------------------------------------------------------- 1 | use http::status::StatusCode; 2 | use leptos::*; 3 | #[cfg(feature = "ssr")] 4 | use leptos_axum::ResponseOptions; 5 | use thiserror::Error; 6 | 7 | #[derive(Debug, Clone, Error)] 8 | pub enum AppError { 9 | #[error("Not Found")] 10 | NotFound, 11 | #[error("Internal Server Error")] 12 | InternalServerError, 13 | } 14 | 15 | impl AppError { 16 | pub fn status_code(&self) -> StatusCode { 17 | match self { 18 | AppError::NotFound => StatusCode::NOT_FOUND, 19 | AppError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR, 20 | } 21 | } 22 | } 23 | 24 | // A basic function to display errors served by the error boundaries. Feel free to do more complicated things 25 | // here than just displaying them 26 | #[component] 27 | pub fn ErrorTemplate( 28 | #[prop(optional)] outside_errors: Option, 29 | #[prop(optional)] errors: Option>, 30 | ) -> impl IntoView { 31 | let errors = match outside_errors { 32 | Some(e) => create_rw_signal(e), 33 | None => match errors { 34 | Some(e) => e, 35 | None => panic!("No Errors found and we expected errors!"), 36 | }, 37 | }; 38 | 39 | // Get Errors from Signal 40 | // Downcast lets us take a type that implements `std::error::Error` 41 | let errors: Vec = errors 42 | .get() 43 | .into_iter() 44 | .filter_map(|(_, v)| v.downcast_ref::().cloned()) 45 | .collect(); 46 | 47 | // Only the response code for the first error is actually sent from the server 48 | // this may be customized by the specific application 49 | #[cfg(feature = "ssr")] 50 | { 51 | let response = use_context::(); 52 | if let Some(response) = response { 53 | response.set_status(errors[0].status_code()); 54 | } 55 | } 56 | 57 | view! { 58 |

"Errors"

59 | {error_code.to_string()} 70 |

"Error: " {error_string}

71 | } 72 | } 73 | /> 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /nix/tests/idmail.nix: -------------------------------------------------------------------------------- 1 | let 2 | token = "averyveryverysecuretokenwithmanycharacters"; 3 | in 4 | (import ./lib.nix) { 5 | name = "idmail-nixos"; 6 | nodes.machine = 7 | { 8 | self, 9 | pkgs, 10 | ... 11 | }: 12 | { 13 | imports = [ self.nixosModules.default ]; 14 | environment.systemPackages = [ pkgs.jq ]; 15 | services.idmail = { 16 | enable = true; 17 | provision = { 18 | enable = true; 19 | users.admin = { 20 | admin = true; 21 | password_hash = "$argon2id$v=19$m=4096,t=3,p=1$YXJnbGluYXJsZ2luMjRvaQ$DXdfVNRSFS1QSvJo7OmXIhAYYtT/D92Ku16DiJwxn8U"; 22 | }; 23 | users.test.password_hash = "$argon2id$v=19$m=4096,t=3,p=1$YXJnbGluYXJsZ2luMjRvaQ$DXdfVNRSFS1QSvJo7OmXIhAYYtT/D92Ku16DiJwxn8U"; 24 | domains."example.com" = { 25 | owner = "admin"; 26 | public = true; 27 | }; 28 | mailboxes."me@example.com" = { 29 | password_hash = "$argon2id$v=19$m=4096,t=3,p=1$YXJnbGluYXJsZ2luMjRvaQ$fiD9Bp3KidVI/E+mGudu6+h9XmF9TU9Bx4VGX0PniDE"; 30 | owner = "test"; 31 | api_token = "%{file:${pkgs.writeText "token" token}}%"; 32 | }; 33 | aliases."somealias@example.com" = { 34 | target = "me@example.com"; 35 | owner = "me@example.com"; 36 | comment = "Used for xyz"; 37 | }; 38 | }; 39 | }; 40 | }; 41 | 42 | testScript = '' 43 | start_all() 44 | 45 | def expect_output(output, expected): 46 | assert output == expected, f""" 47 | Expected output: {repr(expected)} 48 | Actual output: {repr(output)} 49 | """ 50 | 51 | machine.wait_for_unit("idmail.service") 52 | machine.wait_for_open_port(3000) 53 | machine.succeed("curl --fail http://localhost:3000/") 54 | 55 | # Test addy.io endpoint 56 | cmd = [ 57 | "curl --fail -X POST", 58 | "-H \"Content-Type: application/json\"", 59 | "-H \"Accept: application/json\"", 60 | "-H \"Authorization: Bearer ${token}\"", 61 | "--data '{\"domain\":\"example.com\",\"description\":\"An optional comment added to the entry\"}'", 62 | "localhost:3000/api/v1/aliases", 63 | "| jq '.data | has(\"email\")'", 64 | ] 65 | out = machine.succeed(' '.join(cmd)) 66 | expect_output(out, "true\n") 67 | 68 | # Test SimpleLogin endpoint 69 | cmd = [ 70 | "curl --fail -X POST", 71 | "-H \"Content-Type: application/json\"", 72 | "-H \"Accept: application/json\"", 73 | "-H \"Authorization: ${token}\"", 74 | "--data '{\"note\":\"A comment added to the entry\"}'", 75 | "localhost:3000/api/alias/random/new", 76 | "| jq 'has(\"alias\")'", 77 | ] 78 | out = machine.succeed(' '.join(cmd)) 79 | expect_output(out, "true\n") 80 | ''; 81 | } 82 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "idmail" 3 | edition = "2021" 4 | version = "1.0.1" 5 | authors = ["oddlama "] 6 | description = "An email alias and account management interface for self-hosted mailservers" 7 | homepage = "https://github.com/oddlama/idmail" 8 | repository = "https://github.com/oddlama/idmail" 9 | keywords = ["email", "alias", "leptos", "web", "wasm"] 10 | categories = [] 11 | license = "MIT" 12 | 13 | [lib] 14 | crate-type = ["cdylib", "rlib"] 15 | 16 | [dependencies] 17 | anyhow = "1.0.93" 18 | argon2 = {version = "0.5.3", optional = true} 19 | async-trait = { version = "0.1", optional = true } 20 | axum = { version = "0.7", optional = true, features = ["macros"] } 21 | axum-extra = "0.9.6" 22 | axum_session = { version = "0.14.4", optional = true } 23 | axum_session_auth = { version = "0.14.1", optional = true } 24 | axum_session_sqlx = { version = "0.3.0", features = ["sqlite"], optional = true } 25 | chrono = { version = "0.4.38", features = ["serde"] } 26 | chrono-humanize = { version = "0.2.3", features = ["wasmbind"] } 27 | console_error_panic_hook = "0.1" 28 | console_log = "1.0" 29 | email_address = "0.2.9" 30 | faker_rand = "0.1.1" 31 | futures = "0.3" 32 | getrandom = "0.2.15" 33 | hex = "0.4.3" 34 | http = "1.1" 35 | icondata = "0.5.0" 36 | leptos = { version = "0.6", features = ["nightly"] } 37 | leptos-struct-table = "0.13.1" 38 | leptos-use = "0.13.11" 39 | leptos_axum = { version = "0.6", optional = true } 40 | leptos_icons = "0.3.1" 41 | leptos_meta = { version = "0.6", features = ["nightly"] } 42 | leptos_router = { version = "0.6", features = ["nightly"] } 43 | leptos_toaster = { version = "0.1.7", features = ["builtin_toast"] } 44 | log = "0.4" 45 | owo-colors = "4.1.0" 46 | rand = { version = "0.8", features = ["min_const_gen"] } 47 | serde = { version = "1.0", features = ["derive"] } 48 | serde_json = "1.0.133" 49 | server_fn = { version = "0.6", features = ["serde-lite"] } 50 | sqlx = { version = "0.8.2", features = [ "runtime-tokio-rustls", "sqlite", ], optional = true } 51 | thiserror = "2.0.3" 52 | tokio = { version = "1", features = ["full"], optional = true } 53 | toml = "0.8.19" 54 | tower = { version = "0.5.1", features = ["util"], optional = true } 55 | tower-http = { version = "0.6.2", features = ["fs"], optional = true } 56 | tracing = { version = "0.1", optional = true } 57 | tracing-subscriber = "0.3.18" 58 | wasm-bindgen = "0.2" 59 | 60 | [dependencies.web-sys] 61 | version = "0.3" 62 | features = ["Clipboard", "Navigator"] 63 | 64 | [features] 65 | default = ["ssr"] 66 | hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] 67 | ssr = [ 68 | "dep:axum", 69 | "dep:tower", 70 | "dep:tower-http", 71 | "dep:tokio", 72 | "dep:axum_session_auth", 73 | "dep:axum_session_sqlx", 74 | "dep:axum_session", 75 | "dep:async-trait", 76 | "dep:sqlx", 77 | "dep:argon2", 78 | "leptos/ssr", 79 | "leptos_meta/ssr", 80 | "leptos_router/ssr", 81 | "leptos-use/ssr", 82 | "dep:leptos_axum", 83 | ] 84 | 85 | [package.metadata.cargo-all-features] 86 | denylist = ["axum", "tower", "tower-http", "tokio", "sqlx", "leptos_axum"] 87 | skip_feature_sets = [["ssr", "hydrate"]] 88 | 89 | [package.metadata.leptos] 90 | # The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name 91 | output-name = "idmail" 92 | # The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup. 93 | site-root = "target/site" 94 | # The site-root relative folder where all compiled output (JS, WASM and CSS) is written 95 | # Defaults to pkg 96 | site-pkg-dir = "pkg" 97 | # The tailwind input file. 98 | tailwind-input-file = "style/tailwind.css" 99 | # Assets source dir. All files found here will be copied and synchronized to site-root. 100 | # The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir. 101 | # 102 | # Optional. Env: LEPTOS_ASSETS_DIR. 103 | assets-dir = "public" 104 | # The port to use for automatic reload monitoring 105 | reload-port = 3001 106 | # The browserlist query used for optimizing the CSS. 107 | browserquery = "defaults" 108 | # Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head 109 | watch = false 110 | # The environment Leptos will run in, usually either "DEV" or "PROD" 111 | env = "DEV" 112 | # The features to use when compiling the bin target 113 | # 114 | # Optional. Can be over-ridden with the command line parameter --bin-features 115 | bin-features = ["ssr"] 116 | # If the --no-default-features flag should be used when compiling the bin target 117 | # 118 | # Optional. Defaults to false. 119 | bin-default-features = false 120 | # The features to use when compiling the lib target 121 | # 122 | # Optional. Can be over-ridden with the command line parameter --lib-features 123 | lib-features = ["hydrate"] 124 | # If the --no-default-features flag should be used when compiling the lib target 125 | # 126 | # Optional. Defaults to false. 127 | lib-default-features = false 128 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | devshell = { 4 | url = "github:numtide/devshell"; 5 | inputs.nixpkgs.follows = "nixpkgs"; 6 | }; 7 | flake-parts.url = "github:hercules-ci/flake-parts"; 8 | nci = { 9 | url = "github:yusdacra/nix-cargo-integration"; 10 | inputs.nixpkgs.follows = "nixpkgs"; 11 | }; 12 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 13 | pre-commit-hooks = { 14 | url = "github:cachix/pre-commit-hooks.nix"; 15 | inputs.nixpkgs.follows = "nixpkgs"; 16 | }; 17 | treefmt-nix = { 18 | url = "github:numtide/treefmt-nix"; 19 | inputs.nixpkgs.follows = "nixpkgs"; 20 | }; 21 | }; 22 | 23 | outputs = 24 | inputs: 25 | inputs.flake-parts.lib.mkFlake { inherit inputs; } { 26 | imports = [ 27 | inputs.devshell.flakeModule 28 | inputs.flake-parts.flakeModules.easyOverlay 29 | inputs.nci.flakeModule 30 | inputs.pre-commit-hooks.flakeModule 31 | inputs.treefmt-nix.flakeModule 32 | ]; 33 | 34 | systems = [ 35 | "x86_64-linux" 36 | "aarch64-linux" 37 | ]; 38 | 39 | flake = 40 | { config, ... }: 41 | { 42 | nixosModules.default = { 43 | imports = [ ./nix/nixosModules/idmail.nix ]; 44 | nixpkgs.overlays = [ config.overlays.default ]; 45 | }; 46 | }; 47 | 48 | perSystem = 49 | { 50 | config, 51 | lib, 52 | pkgs, 53 | ... 54 | }: 55 | let 56 | projectName = "idmail"; 57 | 58 | tailwindcss = pkgs.nodePackages.tailwindcss.overrideAttrs (_prevAttrs: { 59 | plugins = [ 60 | pkgs.nodePackages."@tailwindcss/aspect-ratio" 61 | pkgs.nodePackages."@tailwindcss/forms" 62 | pkgs.nodePackages."@tailwindcss/language-server" 63 | pkgs.nodePackages."@tailwindcss/line-clamp" 64 | pkgs.nodePackages."@tailwindcss/typography" 65 | ]; 66 | }); 67 | 68 | extraNativeBuildInputs = [ 69 | pkgs.wasm-bindgen-cli 70 | pkgs.binaryen 71 | pkgs.cargo-leptos 72 | tailwindcss 73 | ]; 74 | in 75 | { 76 | devshells.default = { 77 | packages = 78 | [ 79 | config.treefmt.build.wrapper 80 | pkgs.cargo-release 81 | ] 82 | # FIXME: why is this necessary? nci doesn't seem to add them automatically. 83 | ++ extraNativeBuildInputs; 84 | devshell.startup.pre-commit.text = config.pre-commit.installationScript; 85 | }; 86 | 87 | pre-commit.settings.hooks.treefmt.enable = true; 88 | treefmt = { 89 | projectRootFile = "flake.nix"; 90 | programs = { 91 | deadnix.enable = true; 92 | statix.enable = true; 93 | nixfmt.enable = true; 94 | rustfmt.enable = true; 95 | }; 96 | }; 97 | 98 | nci.projects.${projectName} = { 99 | path = ./.; 100 | numtideDevshell = "default"; 101 | }; 102 | nci.crates.${projectName} = { 103 | drvConfig = { 104 | mkDerivation = { 105 | # add trunk and other dependencies 106 | nativeBuildInputs = [ 107 | pkgs.makeWrapper 108 | ] ++ extraNativeBuildInputs; 109 | 110 | # override build phase to build with trunk instead 111 | buildPhase = '' 112 | export -n CARGO_BUILD_TARGET 113 | cargo leptos build --release -vvv 114 | ''; 115 | 116 | installPhase = '' 117 | mkdir -p $out/bin $out/share 118 | cp target/release/${projectName} $out/bin/ 119 | cp -r target/site $out/share/ 120 | wrapProgram $out/bin/${projectName} \ 121 | --set LEPTOS_SITE_ROOT $out/share/site 122 | ''; 123 | 124 | meta = { 125 | description = "idmail, an email alias and account management interface for self-hosted mailservers"; 126 | homepage = "https://github.com/oddlama/idmail"; 127 | license = lib.licenses.mit; 128 | #maintainers = with lib.maintainers; [oddlama]; 129 | mainProgram = "idmail"; 130 | }; 131 | }; 132 | env.RUSTFLAGS = "--cfg=web_sys_unstable_apis"; 133 | env.LEPTOS_ENV = "PROD"; 134 | }; 135 | }; 136 | 137 | packages.default = config.nci.outputs.${projectName}.packages.release; 138 | packages.nixosTest = import ./nix/tests/idmail.nix { 139 | inherit (inputs) self; 140 | inherit pkgs lib; 141 | }; 142 | 143 | overlayAttrs.idmail = config.packages.default; 144 | }; 145 | }; 146 | } 147 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use axum::{ 3 | body::Body as AxumBody, 4 | extract::{Path, State}, 5 | http::Request, 6 | response::{IntoResponse, Response}, 7 | routing::{get, post}, 8 | Router, 9 | }; 10 | use axum_session::{SessionConfig, SessionLayer, SessionStore}; 11 | use axum_session_auth::{AuthConfig, AuthSessionLayer}; 12 | use axum_session_sqlx::SessionSqlitePool; 13 | use idmail::{ 14 | app::App, 15 | auth::{ssr::AuthSession, User}, 16 | fileserv::file_and_error_handler, 17 | provision::provision, 18 | state::AppState, 19 | }; 20 | use leptos::{get_configuration, provide_context}; 21 | use leptos_axum::{generate_route_list, handle_server_fns_with_context, LeptosRoutes}; 22 | use log::{info, warn}; 23 | use sqlx::{sqlite::SqliteConnectOptions, QueryBuilder, SqlitePool}; 24 | 25 | async fn server_fn_handler( 26 | State(app_state): State, 27 | auth_session: AuthSession, 28 | _path: Path, 29 | request: Request, 30 | ) -> impl IntoResponse { 31 | handle_server_fns_with_context( 32 | move || { 33 | provide_context(auth_session.clone()); 34 | provide_context(app_state.pool.clone()); 35 | }, 36 | request, 37 | ) 38 | .await 39 | } 40 | 41 | async fn leptos_routes_handler( 42 | auth_session: AuthSession, 43 | State(app_state): State, 44 | req: Request, 45 | ) -> Response { 46 | let handler = leptos_axum::render_route_with_context( 47 | app_state.leptos_options.clone(), 48 | app_state.routes.clone(), 49 | move || { 50 | provide_context(auth_session.clone()); 51 | provide_context(app_state.pool.clone()); 52 | }, 53 | App, 54 | ); 55 | handler(req).await.into_response() 56 | } 57 | 58 | async fn connect(filename: impl AsRef) -> Result> { 59 | let options = SqliteConnectOptions::new() 60 | .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) 61 | .filename(filename) 62 | .create_if_missing(true); 63 | Ok(SqlitePool::connect_with(options).await?) 64 | } 65 | 66 | #[tokio::main] 67 | async fn main() -> Result<()> { 68 | tracing_subscriber::fmt().without_time().init(); 69 | 70 | let pool = connect("idmail.db").await?; 71 | 72 | // Auth section 73 | let session_config = SessionConfig::default().with_table_name("axum_sessions"); 74 | // Disable user caching 75 | let auth_config = AuthConfig::::default().set_cache(false); 76 | let session_store = 77 | SessionStore::::new(Some(SessionSqlitePool::from(pool.clone())), session_config).await?; 78 | 79 | sqlx::migrate!().run(&pool).await?; 80 | 81 | // Provisioning 82 | provision(&pool).await?; 83 | 84 | // Create admin user if none exist 85 | let admin_user_exists = QueryBuilder::new("SELECT COUNT(*) FROM users WHERE username = 'admin'") 86 | .build_query_scalar::() 87 | .fetch_one(&pool) 88 | .await? 89 | > 0; 90 | if !admin_user_exists { 91 | warn!("admin user doesn't exist in database, recovering..."); 92 | 93 | let mut buf = [0u8; 24]; 94 | getrandom::getrandom(&mut buf)?; 95 | let password = hex::encode(buf); 96 | 97 | let password_hash = idmail::users::mk_password_hash(&password) 98 | .map_err(|e| anyhow!("failed to hash password for admin user: {e}"))?; 99 | sqlx::query("INSERT INTO users (username, password_hash, admin) VALUES ('admin', ?, TRUE)") 100 | .bind(password_hash) 101 | .execute(&pool) 102 | .await 103 | .map(|_| ())?; 104 | 105 | warn!("created admin user with password '{password}'"); 106 | } 107 | 108 | // Setting this to None means we'll be using cargo-leptos and its env vars 109 | let conf = get_configuration(None).await?; 110 | let leptos_options = conf.leptos_options; 111 | let addr = leptos_options.site_addr; 112 | let routes = generate_route_list(App); 113 | 114 | let app_state = AppState { 115 | leptos_options, 116 | pool: pool.clone(), 117 | routes: routes.clone(), 118 | }; 119 | 120 | // build our application with a route 121 | let app = Router::new() 122 | .route("/api/*fn_name", get(server_fn_handler).post(server_fn_handler)) 123 | .route("/api/alias/random/new", post(idmail::api::create_simple_login)) 124 | .route("/api/v1/aliases", post(idmail::api::create_addy_io)) 125 | .leptos_routes_with_handler(routes, get(leptos_routes_handler)) 126 | .fallback(file_and_error_handler) 127 | .layer( 128 | AuthSessionLayer::::new(Some(pool.clone())) 129 | .with_config(auth_config), 130 | ) 131 | .layer(SessionLayer::new(session_store)) 132 | .with_state(app_state); 133 | 134 | // run our app with hyper 135 | // `axum::Server` is a re-export of `hyper::Server` 136 | info!("listening on http://{addr}"); 137 | let listener = tokio::net::TcpListener::bind(&addr).await?; 138 | axum::serve(listener, app.into_make_service()).await?; 139 | Ok(()) 140 | } 141 | -------------------------------------------------------------------------------- /public/font/inter.css: -------------------------------------------------------------------------------- 1 | /* Variable fonts usage: 2 | :root { font-family: "Inter", sans-serif; } 3 | @supports (font-variation-settings: normal) { 4 | :root { font-family: "InterVariable", sans-serif; font-optical-sizing: auto; } 5 | } */ 6 | @font-face { 7 | font-family: InterVariable; 8 | font-style: normal; 9 | font-weight: 100 900; 10 | font-display: swap; 11 | src: url("/font/InterVariable.woff2") format("woff2"); 12 | } 13 | @font-face { 14 | font-family: InterVariable; 15 | font-style: italic; 16 | font-weight: 100 900; 17 | font-display: swap; 18 | src: url("/font/InterVariable-Italic.woff2") format("woff2"); 19 | } 20 | 21 | /* static fonts */ 22 | @font-face { font-family: "Inter"; font-style: normal; font-weight: 100; font-display: swap; src: url("/font/Inter-Thin.woff2") format("woff2"); } 23 | @font-face { font-family: "Inter"; font-style: italic; font-weight: 100; font-display: swap; src: url("/font/Inter-ThinItalic.woff2") format("woff2"); } 24 | @font-face { font-family: "Inter"; font-style: normal; font-weight: 200; font-display: swap; src: url("/font/Inter-ExtraLight.woff2") format("woff2"); } 25 | @font-face { font-family: "Inter"; font-style: italic; font-weight: 200; font-display: swap; src: url("/font/Inter-ExtraLightItalic.woff2") format("woff2"); } 26 | @font-face { font-family: "Inter"; font-style: normal; font-weight: 300; font-display: swap; src: url("/font/Inter-Light.woff2") format("woff2"); } 27 | @font-face { font-family: "Inter"; font-style: italic; font-weight: 300; font-display: swap; src: url("/font/Inter-LightItalic.woff2") format("woff2"); } 28 | @font-face { font-family: "Inter"; font-style: normal; font-weight: 400; font-display: swap; src: url("/font/Inter-Regular.woff2") format("woff2"); } 29 | @font-face { font-family: "Inter"; font-style: italic; font-weight: 400; font-display: swap; src: url("/font/Inter-Italic.woff2") format("woff2"); } 30 | @font-face { font-family: "Inter"; font-style: normal; font-weight: 500; font-display: swap; src: url("/font/Inter-Medium.woff2") format("woff2"); } 31 | @font-face { font-family: "Inter"; font-style: italic; font-weight: 500; font-display: swap; src: url("/font/Inter-MediumItalic.woff2") format("woff2"); } 32 | @font-face { font-family: "Inter"; font-style: normal; font-weight: 600; font-display: swap; src: url("/font/Inter-SemiBold.woff2") format("woff2"); } 33 | @font-face { font-family: "Inter"; font-style: italic; font-weight: 600; font-display: swap; src: url("/font/Inter-SemiBoldItalic.woff2") format("woff2"); } 34 | @font-face { font-family: "Inter"; font-style: normal; font-weight: 700; font-display: swap; src: url("/font/Inter-Bold.woff2") format("woff2"); } 35 | @font-face { font-family: "Inter"; font-style: italic; font-weight: 700; font-display: swap; src: url("/font/Inter-BoldItalic.woff2") format("woff2"); } 36 | @font-face { font-family: "Inter"; font-style: normal; font-weight: 800; font-display: swap; src: url("/font/Inter-ExtraBold.woff2") format("woff2"); } 37 | @font-face { font-family: "Inter"; font-style: italic; font-weight: 800; font-display: swap; src: url("/font/Inter-ExtraBoldItalic.woff2") format("woff2"); } 38 | @font-face { font-family: "Inter"; font-style: normal; font-weight: 900; font-display: swap; src: url("/font/Inter-Black.woff2") format("woff2"); } 39 | @font-face { font-family: "Inter"; font-style: italic; font-weight: 900; font-display: swap; src: url("/font/Inter-BlackItalic.woff2") format("woff2"); } 40 | @font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 100; font-display: swap; src: url("/font/InterDisplay-Thin.woff2") format("woff2"); } 41 | @font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 100; font-display: swap; src: url("/font/InterDisplay-ThinItalic.woff2") format("woff2"); } 42 | @font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 200; font-display: swap; src: url("/font/InterDisplay-ExtraLight.woff2") format("woff2"); } 43 | @font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 200; font-display: swap; src: url("/font/InterDisplay-ExtraLightItalic.woff2") format("woff2"); } 44 | @font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 300; font-display: swap; src: url("/font/InterDisplay-Light.woff2") format("woff2"); } 45 | @font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 300; font-display: swap; src: url("/font/InterDisplay-LightItalic.woff2") format("woff2"); } 46 | @font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 400; font-display: swap; src: url("/font/InterDisplay-Regular.woff2") format("woff2"); } 47 | @font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 400; font-display: swap; src: url("/font/InterDisplay-Italic.woff2") format("woff2"); } 48 | @font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 500; font-display: swap; src: url("/font/InterDisplay-Medium.woff2") format("woff2"); } 49 | @font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 500; font-display: swap; src: url("/font/InterDisplay-MediumItalic.woff2") format("woff2"); } 50 | @font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 600; font-display: swap; src: url("/font/InterDisplay-SemiBold.woff2") format("woff2"); } 51 | @font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 600; font-display: swap; src: url("/font/InterDisplay-SemiBoldItalic.woff2") format("woff2"); } 52 | @font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 700; font-display: swap; src: url("/font/InterDisplay-Bold.woff2") format("woff2"); } 53 | @font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 700; font-display: swap; src: url("/font/InterDisplay-BoldItalic.woff2") format("woff2"); } 54 | @font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 800; font-display: swap; src: url("/font/InterDisplay-ExtraBold.woff2") format("woff2"); } 55 | @font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 800; font-display: swap; src: url("/font/InterDisplay-ExtraBoldItalic.woff2") format("woff2"); } 56 | @font-face { font-family: "InterDisplay"; font-style: normal; font-weight: 900; font-display: swap; src: url("/font/InterDisplay-Black.woff2") format("woff2"); } 57 | @font-face { font-family: "InterDisplay"; font-style: italic; font-weight: 900; font-display: swap; src: url("/font/InterDisplay-BlackItalic.woff2") format("woff2"); } 58 | -------------------------------------------------------------------------------- /src/api.rs: -------------------------------------------------------------------------------- 1 | use crate::{aliases::validate_address, auth::User, state::AppState}; 2 | use axum::{ 3 | extract::{self, rejection::JsonRejection, State}, 4 | response::IntoResponse, 5 | Json, 6 | }; 7 | use axum_extra::extract::WithRejection; 8 | use faker_rand::en_us::internet::Username; 9 | use http::{HeaderMap, StatusCode}; 10 | use rand::seq::SliceRandom; 11 | use rand::{rngs::OsRng, Rng}; 12 | use serde::Deserialize; 13 | use serde_json::json; 14 | use sqlx::QueryBuilder; 15 | use thiserror::Error; 16 | 17 | // We derive `thiserror::Error` 18 | #[derive(Debug, Error)] 19 | pub enum ApiError { 20 | // The `#[from]` attribute generates `From for ApiError` 21 | // implementation. See `thiserror` docs for more information 22 | #[error(transparent)] 23 | JsonExtractorRejection(#[from] JsonRejection), 24 | /// Unauthorized 25 | #[error("Unauthorized")] 26 | Unauthorized(String), 27 | /// Bad Request 28 | #[error("BadRequest")] 29 | BadRequest(String), 30 | /// Internal Server Error 31 | #[error("ServerError")] 32 | ServerError(String), 33 | } 34 | 35 | // We implement `IntoResponse` so ApiError can be used as a response 36 | impl IntoResponse for ApiError { 37 | fn into_response(self) -> axum::response::Response { 38 | let (status, message) = match self { 39 | ApiError::JsonExtractorRejection(json_rejection) => (json_rejection.status(), json_rejection.body_text()), 40 | ApiError::Unauthorized(message) => (StatusCode::UNAUTHORIZED, message), 41 | ApiError::BadRequest(message) => (StatusCode::BAD_REQUEST, message), 42 | ApiError::ServerError(message) => (StatusCode::INTERNAL_SERVER_ERROR, message), 43 | }; 44 | 45 | let payload = json!({ 46 | "error": message, 47 | "statusText": message, 48 | }); 49 | 50 | (status, Json(payload)).into_response() 51 | } 52 | } 53 | 54 | async fn login_with_api_token(app_state: &AppState, headers: &HeaderMap) -> Result { 55 | let Some(api_token) = headers.get("Authorization").and_then(|x| x.to_str().ok()) else { 56 | return Err(ApiError::Unauthorized("Missing API token in request".to_string())); 57 | }; 58 | 59 | let api_token = api_token.strip_prefix("Bearer").unwrap_or(api_token).trim_start(); 60 | let Some(user) = User::get_by_api_token(api_token, &app_state.pool).await else { 61 | return Err(ApiError::Unauthorized("Invalid API token".to_string())); 62 | }; 63 | 64 | log::info!("api token used successfully for user '{}'", user.username); 65 | Ok(user) 66 | } 67 | 68 | async fn allowed_domains(app_state: &AppState, user: &User) -> Result, String> { 69 | let mut query = QueryBuilder::new("SELECT domain FROM domains"); 70 | query.push(" WHERE active = TRUE AND (public = TRUE"); 71 | if let Some(mailbox_owner) = &user.mailbox_owner { 72 | query.push(" OR owner = "); 73 | query.push_bind(mailbox_owner.clone()); 74 | } 75 | query.push(")"); 76 | 77 | query 78 | .build_query_scalar::() 79 | .fetch_all(&app_state.pool) 80 | .await 81 | .map_err(|e| e.to_string()) 82 | } 83 | 84 | async fn create_random_alias( 85 | app_state: &AppState, 86 | user: &User, 87 | domain: Option, 88 | comment: &str, 89 | ) -> Result<(String, String, String), ApiError> { 90 | let target = &user.username; 91 | let owner = &user.username; 92 | let allowed_domains = allowed_domains(app_state, user).await.map_err(ApiError::BadRequest)?; 93 | 94 | let Some(domain) = domain.or_else(|| allowed_domains.choose(&mut OsRng).cloned()) else { 95 | return Err(ApiError::BadRequest("no usable domains are configured".to_string())); 96 | }; 97 | 98 | let alias = OsRng.gen::().to_string(); 99 | 100 | // Check if resulting address is valid 101 | if !allowed_domains.contains(&domain) { 102 | return Err(ApiError::BadRequest(format!( 103 | "Chosen domain '{}' does not exist or is not allowed to be used", 104 | domain 105 | ))); 106 | }; 107 | 108 | let address = validate_address(&alias, &domain, false /* never allow reserved */) 109 | .map_err(|e| ApiError::BadRequest(e.to_string()))?; 110 | 111 | let mut query = QueryBuilder::new("INSERT INTO aliases (address, domain, target, comment, active, owner)"); 112 | query.push("SELECT "); 113 | query.push_bind(&address); 114 | query.push(", "); 115 | query.push_bind(&domain); 116 | query.push(", "); 117 | query.push_bind(target); 118 | query.push(", "); 119 | query.push_bind(comment); 120 | query.push(", "); 121 | query.push_bind(true); 122 | query.push(", "); 123 | query.push_bind(owner); 124 | // make sure that no mailbox exists with that address 125 | query.push(" WHERE NOT EXISTS (SELECT * FROM mailboxes WHERE address = "); 126 | query.push_bind(&address); 127 | query.push(")"); 128 | 129 | if query 130 | .build() 131 | .execute(&app_state.pool) 132 | .await 133 | .map_err(|e| { 134 | log::error!("database error while creating alias via api token: {e}"); 135 | ApiError::ServerError("database error".to_string()) 136 | })? 137 | .rows_affected() 138 | == 0 139 | { 140 | return Err(ApiError::ServerError( 141 | "This address is already in use by a mailbox!".to_string(), 142 | )); 143 | } 144 | 145 | Ok((address, alias, domain)) 146 | } 147 | 148 | #[derive(Deserialize)] 149 | pub struct SimpleLoginRequest { 150 | note: String, 151 | } 152 | 153 | pub async fn create_simple_login( 154 | State(app_state): State, 155 | headers: HeaderMap, 156 | WithRejection(extract::Json(body), _): WithRejection, ApiError>, 157 | ) -> Result { 158 | let user = login_with_api_token(&app_state, &headers).await?; 159 | let (address, _, _) = create_random_alias(&app_state, &user, None, &body.note).await?; 160 | 161 | Ok(( 162 | StatusCode::CREATED, 163 | Json(json!({ 164 | "alias": address, 165 | })), 166 | ) 167 | .into_response()) 168 | } 169 | 170 | #[derive(Deserialize)] 171 | pub struct AddyIoRequest { 172 | domain: String, 173 | description: Option, 174 | } 175 | 176 | pub async fn create_addy_io( 177 | State(app_state): State, 178 | headers: HeaderMap, 179 | WithRejection(extract::Json(body), _): WithRejection, ApiError>, 180 | ) -> Result { 181 | let user = login_with_api_token(&app_state, &headers).await?; 182 | let description = body.description.unwrap_or("".to_string()); 183 | let (address, _, domain) = create_random_alias( 184 | &app_state, 185 | &user, 186 | (!body.domain.is_empty() && body.domain != "random").then_some(body.domain), 187 | &description, 188 | ) 189 | .await?; 190 | 191 | Ok(( 192 | StatusCode::CREATED, 193 | Json(json!({ 194 | "data": { 195 | "id": "00000000-0000-0000-0000-000000000000", 196 | "user_id": "00000000-0000-0000-0000-000000000000", 197 | "aliasable_id": null, 198 | "aliasable_type": null, 199 | "local_part": "00000000-0000-0000-0000-000000000000", 200 | "extension": null, 201 | "domain": domain, 202 | "email": address, 203 | "active": true, 204 | "description": description, 205 | "from_name": null, 206 | "emails_forwarded": 0, 207 | "emails_blocked": 0, 208 | "emails_replied": 0, 209 | "emails_sent": 0, 210 | "recipients": [], 211 | "last_forwarded": "2000-01-01 00:00:00", 212 | "last_blocked": null, 213 | "last_replied": null, 214 | "last_sent": null, 215 | "created_at": "2000-01-01 00:00:00", 216 | "updated_at": "2000-01-01 00:00:00", 217 | "deleted_at": null 218 | } 219 | })), 220 | ) 221 | .into_response()) 222 | } 223 | -------------------------------------------------------------------------------- /nix/nixosModules/idmail.nix: -------------------------------------------------------------------------------- 1 | { 2 | config, 3 | lib, 4 | pkgs, 5 | ... 6 | }: 7 | let 8 | inherit (lib) 9 | filterAttrsRecursive 10 | getExe 11 | mkEnableOption 12 | mkIf 13 | mkOption 14 | mkPackageOption 15 | removeAttrs 16 | types 17 | ; 18 | 19 | cfg = config.services.idmail; 20 | defaultDataDir = "/var/lib/idmail"; 21 | 22 | provisionWithoutNull = filterAttrsRecursive (_: v: v != null) ( 23 | removeAttrs cfg.provision [ "enable" ] 24 | ); 25 | provisionToml = (pkgs.formats.toml { }).generate "idmail-provision.toml" provisionWithoutNull; 26 | in 27 | { 28 | options.services.idmail = { 29 | enable = mkEnableOption "idmail"; 30 | package = mkPackageOption pkgs "idmail" { }; 31 | 32 | user = mkOption { 33 | default = "idmail"; 34 | type = types.str; 35 | description = "The user as which the service will be executed. Only creates a user 'idmail' if left untouched."; 36 | }; 37 | 38 | dataDir = mkOption { 39 | default = defaultDataDir; 40 | type = types.path; 41 | description = "The data directory where the database will be stored"; 42 | }; 43 | 44 | openFirewall = mkOption { 45 | type = types.bool; 46 | default = false; 47 | description = "Whether to open the relevant port for idmail in the firewall. It is recommended to use a reverse proxy with TLS termination instead."; 48 | }; 49 | 50 | host = mkOption { 51 | type = types.str; 52 | description = "Host to bind to, must be an IP address."; 53 | default = "127.0.0.1"; 54 | }; 55 | 56 | port = mkOption { 57 | type = types.port; 58 | default = 3000; 59 | description = "Port to bind to"; 60 | }; 61 | 62 | provision = { 63 | enable = mkEnableOption "provisioning of idmail"; 64 | 65 | users = mkOption { 66 | default = { }; 67 | type = types.attrsOf ( 68 | types.submodule { 69 | options = { 70 | password_hash = mkOption { 71 | type = types.str; 72 | description = '' 73 | Password hash, should be a argon2id hash. 74 | Can be generated with: `echo -n "whatever" | argon2 somerandomsalt -id` 75 | Also accepts "%{file:/path/to/secret}%" to refer to the contents of a file. 76 | ''; 77 | }; 78 | admin = mkOption { 79 | type = types.bool; 80 | default = false; 81 | description = ''Whether the user should be an admin.''; 82 | }; 83 | active = mkOption { 84 | type = types.bool; 85 | default = true; 86 | description = ''Whether the user should be active.''; 87 | }; 88 | }; 89 | } 90 | ); 91 | }; 92 | 93 | domains = mkOption { 94 | default = { }; 95 | type = types.attrsOf ( 96 | types.submodule { 97 | options = { 98 | owner = mkOption { 99 | type = types.str; 100 | description = '' 101 | The user which owns this domain. Allows that user to modify 102 | the catch all address and the domain's active state. 103 | Creation and deletion of any domain is always restricted to admins only. 104 | ''; 105 | }; 106 | catch_all = mkOption { 107 | type = types.nullOr types.str; 108 | default = null; 109 | description = ''A catch-all address for this domain.''; 110 | }; 111 | public = mkOption { 112 | type = types.bool; 113 | default = false; 114 | description = '' 115 | Whether the domain should be available for use by any registered 116 | user instead of just the owner. Admins can always use any domain, 117 | regardless of this setting. 118 | ''; 119 | }; 120 | active = mkOption { 121 | type = types.bool; 122 | default = true; 123 | description = ''Whether the domain should be active.''; 124 | }; 125 | }; 126 | } 127 | ); 128 | }; 129 | 130 | mailboxes = mkOption { 131 | default = { }; 132 | type = types.attrsOf ( 133 | types.submodule { 134 | options = { 135 | password_hash = mkOption { 136 | type = types.str; 137 | description = '' 138 | Password hash, should be a argon2id hash. 139 | Can be generated with: `echo -n "whatever" | argon2 somerandomsalt -id` 140 | Also accepts "%{file:/path/to/secret}%" to refer to the contents of a file. 141 | ''; 142 | }; 143 | owner = mkOption { 144 | type = types.str; 145 | description = ''The user which owns this mailbox. That user has full control over the mailbox and its aliases.''; 146 | }; 147 | api_token = mkOption { 148 | type = types.nullOr types.str; 149 | default = null; 150 | description = '' 151 | An API token for this mailbox to allow alias creation via the API endpoints. 152 | Optional. Default: None (API access disabled) 153 | Minimum length 16. Must be unique! 154 | Also accepts "%{file:/path/to/secret}%" to refer to the contents of a file. 155 | ''; 156 | }; 157 | active = mkOption { 158 | type = types.bool; 159 | default = true; 160 | description = ''Whether the mailbox should be active.''; 161 | }; 162 | }; 163 | } 164 | ); 165 | }; 166 | 167 | aliases = mkOption { 168 | default = { }; 169 | type = types.attrsOf ( 170 | types.submodule { 171 | options = { 172 | target = mkOption { 173 | type = types.str; 174 | description = '' 175 | The target address for this alias. The WebUI restricts users to only 176 | target mailboxes they own. Admins and this provisioning file 177 | have no such restrictions. 178 | ''; 179 | }; 180 | owner = mkOption { 181 | type = types.str; 182 | description = '' 183 | The user/mailbox which owns this alias. If owned by a mailbox, 184 | the user owning the mailbox transitively owns this. 185 | ''; 186 | }; 187 | comment = mkOption { 188 | type = types.nullOr types.str; 189 | default = null; 190 | description = ''A comment to store alongside this alias.''; 191 | }; 192 | active = mkOption { 193 | type = types.bool; 194 | default = true; 195 | description = ''Whether the alias should be active.''; 196 | }; 197 | }; 198 | } 199 | ); 200 | }; 201 | }; 202 | }; 203 | 204 | config = mkIf cfg.enable { 205 | users = mkIf (cfg.user == "idmail") { 206 | groups.idmail = { }; 207 | users.idmail = { 208 | isSystemUser = true; 209 | group = "idmail"; 210 | home = defaultDataDir; 211 | }; 212 | }; 213 | 214 | networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ]; 215 | 216 | systemd.services.idmail = { 217 | description = "An email alias and account management interface for self-hosted mailservers"; 218 | wantedBy = [ "multi-user.target" ]; 219 | after = [ "network.target" ]; 220 | 221 | environment.LEPTOS_SITE_ADDR = "${cfg.host}:${toString cfg.port}"; 222 | environment.IDMAIL_PROVISION = mkIf cfg.provision.enable provisionToml; 223 | 224 | serviceConfig = { 225 | Restart = "on-failure"; 226 | ExecStart = getExe cfg.package; 227 | User = cfg.user; 228 | 229 | StateDirectory = mkIf (cfg.dataDir == defaultDataDir) "idmail"; 230 | StateDirectoryMode = mkIf (cfg.dataDir == defaultDataDir) "750"; 231 | WorkingDirectory = cfg.dataDir; 232 | ReadWriteDirectories = [ cfg.dataDir ]; 233 | 234 | # Hardening 235 | CapabilityBoundingSet = ""; 236 | LockPersonality = true; 237 | MemoryDenyWriteExecute = true; 238 | NoNewPrivileges = true; 239 | PrivateUsers = true; 240 | PrivateTmp = true; 241 | PrivateDevices = true; 242 | PrivateMounts = true; 243 | ProtectClock = true; 244 | ProtectControlGroups = true; 245 | ProtectHome = true; 246 | ProtectHostname = true; 247 | ProtectKernelLogs = true; 248 | ProtectKernelModules = true; 249 | ProtectKernelTunables = true; 250 | ProtectProc = "invisible"; 251 | ProtectSystem = "strict"; 252 | RemoveIPC = true; 253 | RestrictAddressFamilies = [ 254 | "AF_INET" 255 | "AF_INET6" 256 | "AF_UNIX" 257 | ]; 258 | RestrictNamespaces = true; 259 | RestrictRealtime = true; 260 | RestrictSUIDSGID = true; 261 | SystemCallArchitectures = "native"; 262 | SystemCallFilter = [ 263 | "@system-service" 264 | "~@privileged" 265 | ]; 266 | UMask = "0027"; 267 | }; 268 | }; 269 | }; 270 | } 271 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "crane": { 4 | "flake": false, 5 | "locked": { 6 | "lastModified": 1758758545, 7 | "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=", 8 | "owner": "ipetkov", 9 | "repo": "crane", 10 | "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "owner": "ipetkov", 15 | "ref": "v0.21.1", 16 | "repo": "crane", 17 | "type": "github" 18 | } 19 | }, 20 | "devshell": { 21 | "inputs": { 22 | "nixpkgs": [ 23 | "nixpkgs" 24 | ] 25 | }, 26 | "locked": { 27 | "lastModified": 1764011051, 28 | "narHash": "sha256-M7SZyPZiqZUR/EiiBJnmyUbOi5oE/03tCeFrTiUZchI=", 29 | "owner": "numtide", 30 | "repo": "devshell", 31 | "rev": "17ed8d9744ebe70424659b0ef74ad6d41fc87071", 32 | "type": "github" 33 | }, 34 | "original": { 35 | "owner": "numtide", 36 | "repo": "devshell", 37 | "type": "github" 38 | } 39 | }, 40 | "dream2nix": { 41 | "inputs": { 42 | "nixpkgs": [ 43 | "nci", 44 | "nixpkgs" 45 | ], 46 | "purescript-overlay": "purescript-overlay", 47 | "pyproject-nix": "pyproject-nix" 48 | }, 49 | "locked": { 50 | "lastModified": 1764021028, 51 | "narHash": "sha256-4OlkDA0yJyqt5iTX9NqtHNghvkWNzYqmtX7FxDmEXt4=", 52 | "owner": "nix-community", 53 | "repo": "dream2nix", 54 | "rev": "ee20942e4524d3458a91108716c847a2d4299d2e", 55 | "type": "github" 56 | }, 57 | "original": { 58 | "owner": "nix-community", 59 | "repo": "dream2nix", 60 | "type": "github" 61 | } 62 | }, 63 | "flake-compat": { 64 | "flake": false, 65 | "locked": { 66 | "lastModified": 1696426674, 67 | "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", 68 | "owner": "edolstra", 69 | "repo": "flake-compat", 70 | "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 71 | "type": "github" 72 | }, 73 | "original": { 74 | "owner": "edolstra", 75 | "repo": "flake-compat", 76 | "type": "github" 77 | } 78 | }, 79 | "flake-compat_2": { 80 | "flake": false, 81 | "locked": { 82 | "lastModified": 1761588595, 83 | "narHash": "sha256-XKUZz9zewJNUj46b4AJdiRZJAvSZ0Dqj2BNfXvFlJC4=", 84 | "owner": "edolstra", 85 | "repo": "flake-compat", 86 | "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5", 87 | "type": "github" 88 | }, 89 | "original": { 90 | "owner": "edolstra", 91 | "repo": "flake-compat", 92 | "type": "github" 93 | } 94 | }, 95 | "flake-parts": { 96 | "inputs": { 97 | "nixpkgs-lib": "nixpkgs-lib" 98 | }, 99 | "locked": { 100 | "lastModified": 1763759067, 101 | "narHash": "sha256-LlLt2Jo/gMNYAwOgdRQBrsRoOz7BPRkzvNaI/fzXi2Q=", 102 | "owner": "hercules-ci", 103 | "repo": "flake-parts", 104 | "rev": "2cccadc7357c0ba201788ae99c4dfa90728ef5e0", 105 | "type": "github" 106 | }, 107 | "original": { 108 | "owner": "hercules-ci", 109 | "repo": "flake-parts", 110 | "type": "github" 111 | } 112 | }, 113 | "gitignore": { 114 | "inputs": { 115 | "nixpkgs": [ 116 | "pre-commit-hooks", 117 | "nixpkgs" 118 | ] 119 | }, 120 | "locked": { 121 | "lastModified": 1709087332, 122 | "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", 123 | "owner": "hercules-ci", 124 | "repo": "gitignore.nix", 125 | "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 126 | "type": "github" 127 | }, 128 | "original": { 129 | "owner": "hercules-ci", 130 | "repo": "gitignore.nix", 131 | "type": "github" 132 | } 133 | }, 134 | "mk-naked-shell": { 135 | "flake": false, 136 | "locked": { 137 | "lastModified": 1681286841, 138 | "narHash": "sha256-3XlJrwlR0nBiREnuogoa5i1b4+w/XPe0z8bbrJASw0g=", 139 | "owner": "90-008", 140 | "repo": "mk-naked-shell", 141 | "rev": "7612f828dd6f22b7fb332cc69440e839d7ffe6bd", 142 | "type": "github" 143 | }, 144 | "original": { 145 | "owner": "90-008", 146 | "repo": "mk-naked-shell", 147 | "type": "github" 148 | } 149 | }, 150 | "nci": { 151 | "inputs": { 152 | "crane": "crane", 153 | "dream2nix": "dream2nix", 154 | "mk-naked-shell": "mk-naked-shell", 155 | "nixpkgs": [ 156 | "nixpkgs" 157 | ], 158 | "parts": "parts", 159 | "rust-overlay": "rust-overlay", 160 | "treefmt": "treefmt" 161 | }, 162 | "locked": { 163 | "lastModified": 1764656497, 164 | "narHash": "sha256-gJ4D+zLKh1cuH5nog6BKirFi7y42iQxScAKJbWA6QQM=", 165 | "owner": "yusdacra", 166 | "repo": "nix-cargo-integration", 167 | "rev": "c585c5cebfcc8f700ba91a681bedee007f110646", 168 | "type": "github" 169 | }, 170 | "original": { 171 | "owner": "yusdacra", 172 | "repo": "nix-cargo-integration", 173 | "type": "github" 174 | } 175 | }, 176 | "nixpkgs": { 177 | "locked": { 178 | "lastModified": 1764517877, 179 | "narHash": "sha256-pp3uT4hHijIC8JUK5MEqeAWmParJrgBVzHLNfJDZxg4=", 180 | "owner": "NixOS", 181 | "repo": "nixpkgs", 182 | "rev": "2d293cbfa5a793b4c50d17c05ef9e385b90edf6c", 183 | "type": "github" 184 | }, 185 | "original": { 186 | "owner": "NixOS", 187 | "ref": "nixos-unstable", 188 | "repo": "nixpkgs", 189 | "type": "github" 190 | } 191 | }, 192 | "nixpkgs-lib": { 193 | "locked": { 194 | "lastModified": 1761765539, 195 | "narHash": "sha256-b0yj6kfvO8ApcSE+QmA6mUfu8IYG6/uU28OFn4PaC8M=", 196 | "owner": "nix-community", 197 | "repo": "nixpkgs.lib", 198 | "rev": "719359f4562934ae99f5443f20aa06c2ffff91fc", 199 | "type": "github" 200 | }, 201 | "original": { 202 | "owner": "nix-community", 203 | "repo": "nixpkgs.lib", 204 | "type": "github" 205 | } 206 | }, 207 | "parts": { 208 | "inputs": { 209 | "nixpkgs-lib": [ 210 | "nci", 211 | "nixpkgs" 212 | ] 213 | }, 214 | "locked": { 215 | "lastModified": 1763759067, 216 | "narHash": "sha256-LlLt2Jo/gMNYAwOgdRQBrsRoOz7BPRkzvNaI/fzXi2Q=", 217 | "owner": "hercules-ci", 218 | "repo": "flake-parts", 219 | "rev": "2cccadc7357c0ba201788ae99c4dfa90728ef5e0", 220 | "type": "github" 221 | }, 222 | "original": { 223 | "owner": "hercules-ci", 224 | "repo": "flake-parts", 225 | "type": "github" 226 | } 227 | }, 228 | "pre-commit-hooks": { 229 | "inputs": { 230 | "flake-compat": "flake-compat_2", 231 | "gitignore": "gitignore", 232 | "nixpkgs": [ 233 | "nixpkgs" 234 | ] 235 | }, 236 | "locked": { 237 | "lastModified": 1763988335, 238 | "narHash": "sha256-QlcnByMc8KBjpU37rbq5iP7Cp97HvjRP0ucfdh+M4Qc=", 239 | "owner": "cachix", 240 | "repo": "pre-commit-hooks.nix", 241 | "rev": "50b9238891e388c9fdc6a5c49e49c42533a1b5ce", 242 | "type": "github" 243 | }, 244 | "original": { 245 | "owner": "cachix", 246 | "repo": "pre-commit-hooks.nix", 247 | "type": "github" 248 | } 249 | }, 250 | "purescript-overlay": { 251 | "inputs": { 252 | "flake-compat": "flake-compat", 253 | "nixpkgs": [ 254 | "nci", 255 | "dream2nix", 256 | "nixpkgs" 257 | ], 258 | "slimlock": "slimlock" 259 | }, 260 | "locked": { 261 | "lastModified": 1728546539, 262 | "narHash": "sha256-Sws7w0tlnjD+Bjck1nv29NjC5DbL6nH5auL9Ex9Iz2A=", 263 | "owner": "thomashoneyman", 264 | "repo": "purescript-overlay", 265 | "rev": "4ad4c15d07bd899d7346b331f377606631eb0ee4", 266 | "type": "github" 267 | }, 268 | "original": { 269 | "owner": "thomashoneyman", 270 | "repo": "purescript-overlay", 271 | "type": "github" 272 | } 273 | }, 274 | "pyproject-nix": { 275 | "inputs": { 276 | "nixpkgs": [ 277 | "nci", 278 | "dream2nix", 279 | "nixpkgs" 280 | ] 281 | }, 282 | "locked": { 283 | "lastModified": 1752481895, 284 | "narHash": "sha256-luVj97hIMpCbwhx3hWiRwjP2YvljWy8FM+4W9njDhLA=", 285 | "owner": "pyproject-nix", 286 | "repo": "pyproject.nix", 287 | "rev": "16ee295c25107a94e59a7fc7f2e5322851781162", 288 | "type": "github" 289 | }, 290 | "original": { 291 | "owner": "pyproject-nix", 292 | "repo": "pyproject.nix", 293 | "type": "github" 294 | } 295 | }, 296 | "root": { 297 | "inputs": { 298 | "devshell": "devshell", 299 | "flake-parts": "flake-parts", 300 | "nci": "nci", 301 | "nixpkgs": "nixpkgs", 302 | "pre-commit-hooks": "pre-commit-hooks", 303 | "treefmt-nix": "treefmt-nix" 304 | } 305 | }, 306 | "rust-overlay": { 307 | "inputs": { 308 | "nixpkgs": [ 309 | "nci", 310 | "nixpkgs" 311 | ] 312 | }, 313 | "locked": { 314 | "lastModified": 1764643237, 315 | "narHash": "sha256-6Ezx9DqVv5UZ7DBK9rcNwBuQUENFyWPS7M09I+FvNao=", 316 | "owner": "oxalica", 317 | "repo": "rust-overlay", 318 | "rev": "e66d6b924ac59e6c722f69332f6540ea57c69233", 319 | "type": "github" 320 | }, 321 | "original": { 322 | "owner": "oxalica", 323 | "repo": "rust-overlay", 324 | "type": "github" 325 | } 326 | }, 327 | "slimlock": { 328 | "inputs": { 329 | "nixpkgs": [ 330 | "nci", 331 | "dream2nix", 332 | "purescript-overlay", 333 | "nixpkgs" 334 | ] 335 | }, 336 | "locked": { 337 | "lastModified": 1688756706, 338 | "narHash": "sha256-xzkkMv3neJJJ89zo3o2ojp7nFeaZc2G0fYwNXNJRFlo=", 339 | "owner": "thomashoneyman", 340 | "repo": "slimlock", 341 | "rev": "cf72723f59e2340d24881fd7bf61cb113b4c407c", 342 | "type": "github" 343 | }, 344 | "original": { 345 | "owner": "thomashoneyman", 346 | "repo": "slimlock", 347 | "type": "github" 348 | } 349 | }, 350 | "treefmt": { 351 | "inputs": { 352 | "nixpkgs": [ 353 | "nci", 354 | "nixpkgs" 355 | ] 356 | }, 357 | "locked": { 358 | "lastModified": 1762938485, 359 | "narHash": "sha256-AlEObg0syDl+Spi4LsZIBrjw+snSVU4T8MOeuZJUJjM=", 360 | "owner": "numtide", 361 | "repo": "treefmt-nix", 362 | "rev": "5b4ee75aeefd1e2d5a1cc43cf6ba65eba75e83e4", 363 | "type": "github" 364 | }, 365 | "original": { 366 | "owner": "numtide", 367 | "repo": "treefmt-nix", 368 | "type": "github" 369 | } 370 | }, 371 | "treefmt-nix": { 372 | "inputs": { 373 | "nixpkgs": [ 374 | "nixpkgs" 375 | ] 376 | }, 377 | "locked": { 378 | "lastModified": 1762938485, 379 | "narHash": "sha256-AlEObg0syDl+Spi4LsZIBrjw+snSVU4T8MOeuZJUJjM=", 380 | "owner": "numtide", 381 | "repo": "treefmt-nix", 382 | "rev": "5b4ee75aeefd1e2d5a1cc43cf6ba65eba75e83e4", 383 | "type": "github" 384 | }, 385 | "original": { 386 | "owner": "numtide", 387 | "repo": "treefmt-nix", 388 | "type": "github" 389 | } 390 | } 391 | }, 392 | "root": "root", 393 | "version": 7 394 | } 395 | -------------------------------------------------------------------------------- /src/provision.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use self::state::State; 4 | use anyhow::{bail, Context, Result}; 5 | use owo_colors::OwoColorize; 6 | use sqlx::{QueryBuilder, SqlitePool}; 7 | 8 | mod state { 9 | use serde::Deserialize; 10 | use std::collections::HashMap; 11 | 12 | #[derive(Debug, Deserialize)] 13 | pub struct User { 14 | pub password_hash: String, 15 | #[serde(default = "default_false")] 16 | pub admin: bool, 17 | #[serde(default = "default_true")] 18 | pub active: bool, 19 | } 20 | 21 | #[derive(Debug, Deserialize)] 22 | pub struct Domain { 23 | #[serde(default)] 24 | pub catch_all: Option, 25 | #[serde(default = "default_false")] 26 | pub public: bool, 27 | #[serde(default = "default_true")] 28 | pub active: bool, 29 | pub owner: String, 30 | } 31 | 32 | #[derive(Debug, Deserialize)] 33 | pub struct Mailbox { 34 | pub password_hash: String, 35 | #[serde(default)] 36 | pub api_token: Option, 37 | #[serde(default = "default_true")] 38 | pub active: bool, 39 | pub owner: String, 40 | } 41 | 42 | #[derive(Debug, Deserialize)] 43 | pub struct Alias { 44 | pub target: String, 45 | #[serde(default)] 46 | pub comment: Option, 47 | #[serde(default = "default_true")] 48 | pub active: bool, 49 | pub owner: String, 50 | } 51 | 52 | #[derive(Debug, Deserialize)] 53 | pub struct State { 54 | #[serde(default)] 55 | pub users: HashMap, 56 | #[serde(default)] 57 | pub domains: HashMap, 58 | #[serde(default)] 59 | pub mailboxes: HashMap, 60 | #[serde(default)] 61 | pub aliases: HashMap, 62 | } 63 | 64 | fn default_false() -> bool { 65 | false 66 | } 67 | fn default_true() -> bool { 68 | true 69 | } 70 | } 71 | 72 | fn value_or_file(value: String) -> Result { 73 | if let Some(file) = value.strip_prefix("%{file:").and_then(|x| x.strip_suffix("}%")) { 74 | Ok(std::fs::read_to_string(file)?.trim().to_string()) 75 | } else { 76 | Ok(value) 77 | } 78 | } 79 | 80 | pub async fn select_provisioned(pool: &SqlitePool, table: &str, index_column: &str) -> Result> { 81 | let ret = sqlx::query_scalar(&format!("SELECT {index_column} FROM {table} WHERE provisioned = TRUE")) 82 | .fetch_all(pool) 83 | .await?; 84 | Ok(ret.into_iter().collect()) 85 | } 86 | 87 | pub async fn delete_orphans( 88 | pool: &SqlitePool, 89 | table: &str, 90 | index_column: &str, 91 | orphans: &HashSet, 92 | ) -> Result<()> { 93 | for orphan in orphans { 94 | let mut query = QueryBuilder::new(&format!("DELETE FROM {table} WHERE {index_column} = ")); 95 | query.push_bind(orphan); 96 | query.build().execute(pool).await?; 97 | } 98 | Ok(()) 99 | } 100 | 101 | pub async fn provision_users(pool: &SqlitePool, state: &State) -> Result<()> { 102 | let known_users = select_provisioned(pool, "users", "username").await?; 103 | let orphaned_users = &known_users - &state.users.keys().cloned().collect::>(); 104 | 105 | log::info!( 106 | "Provisioning {} users ({}, {})", 107 | state.users.len().yellow(), 108 | format!("-{}", orphaned_users.len()).red(), 109 | format!("+{}", state.users.len() - known_users.len() + orphaned_users.len()).green(), 110 | ); 111 | delete_orphans(pool, "users", "username", &orphaned_users).await?; 112 | 113 | for (name, user) in &state.users { 114 | let password_hash = value_or_file(user.password_hash.clone())?; 115 | let mut query = QueryBuilder::new("INSERT INTO users (username, password_hash, admin, active, provisioned)"); 116 | query.push(" VALUES ("); 117 | query.push_bind(name); 118 | query.push(", "); 119 | query.push_bind(&password_hash); 120 | query.push(", "); 121 | query.push_bind(user.admin); 122 | query.push(", "); 123 | query.push_bind(user.active); 124 | query.push(", TRUE)"); 125 | 126 | query.push(" ON CONFLICT (username) DO UPDATE SET"); 127 | query.push(" password_hash = "); 128 | query.push_bind(&password_hash); 129 | query.push(", admin = "); 130 | query.push_bind(user.admin); 131 | query.push(", active = "); 132 | query.push_bind(user.active); 133 | query.push(", provisioned = TRUE"); 134 | 135 | query.build().execute(pool).await?; 136 | } 137 | 138 | Ok(()) 139 | } 140 | 141 | pub async fn provision_domains(pool: &SqlitePool, state: &State) -> Result<()> { 142 | let known_domains = select_provisioned(pool, "domains", "domain").await?; 143 | let orphaned_domains = &known_domains - &state.domains.keys().cloned().collect::>(); 144 | 145 | log::info!( 146 | "Provisioning {} domains ({}, {})", 147 | state.domains.len().yellow(), 148 | format!("-{}", orphaned_domains.len()).red(), 149 | format!( 150 | "+{}", 151 | state.domains.len() - known_domains.len() + orphaned_domains.len() 152 | ) 153 | .green(), 154 | ); 155 | delete_orphans(pool, "domains", "domain", &orphaned_domains).await?; 156 | 157 | for (name, domain) in &state.domains { 158 | if !state.users.contains_key(&domain.owner) { 159 | bail!( 160 | "Failed to provision domain '{name}': Owner '{}' must be a provisioned user", 161 | domain.owner 162 | ); 163 | } 164 | 165 | let catch_all = domain.catch_all.as_deref().unwrap_or(""); 166 | let mut query = 167 | QueryBuilder::new("INSERT INTO domains (domain, catch_all, public, active, owner, provisioned)"); 168 | 169 | query.push(" VALUES ("); 170 | query.push_bind(name); 171 | query.push(", "); 172 | query.push_bind(catch_all); 173 | query.push(", "); 174 | query.push_bind(domain.public); 175 | query.push(", "); 176 | query.push_bind(domain.active); 177 | query.push(", "); 178 | query.push_bind(&domain.owner); 179 | query.push(", TRUE)"); 180 | 181 | query.push(" ON CONFLICT (domain) DO UPDATE SET"); 182 | query.push(" catch_all = "); 183 | query.push_bind(catch_all); 184 | query.push(", public = "); 185 | query.push_bind(domain.public); 186 | query.push(", active = "); 187 | query.push_bind(domain.active); 188 | query.push(", owner = "); 189 | query.push_bind(&domain.owner); 190 | query.push(", provisioned = TRUE"); 191 | 192 | query.build().execute(pool).await?; 193 | } 194 | 195 | Ok(()) 196 | } 197 | 198 | pub async fn provision_mailboxes(pool: &SqlitePool, state: &State) -> Result<()> { 199 | let known_mailboxes = select_provisioned(pool, "mailboxes", "address").await?; 200 | let orphaned_mailboxes = &known_mailboxes - &state.mailboxes.keys().cloned().collect::>(); 201 | 202 | log::info!( 203 | "Provisioning {} mailboxes ({}, {})", 204 | state.mailboxes.len().yellow(), 205 | format!("-{}", orphaned_mailboxes.len()).red(), 206 | format!( 207 | "+{}", 208 | state.mailboxes.len() - known_mailboxes.len() + orphaned_mailboxes.len() 209 | ) 210 | .green(), 211 | ); 212 | delete_orphans(pool, "mailboxes", "address", &orphaned_mailboxes).await?; 213 | 214 | for (name, mailbox) in &state.mailboxes { 215 | let Some((_localpart, domain)) = name.split_once('@') else { 216 | bail!("Failed to provision mailbox '{name}': Invalid address"); 217 | }; 218 | 219 | if !state.domains.contains_key(domain) { 220 | bail!("Failed to provision mailbox '{name}': Domain '{domain}' must be a provisioned domain"); 221 | } 222 | if !state.users.contains_key(&mailbox.owner) { 223 | bail!( 224 | "Failed to provision mailbox '{name}': Owner '{}' must be a provisioned user", 225 | mailbox.owner 226 | ); 227 | } 228 | 229 | let password_hash = value_or_file(mailbox.password_hash.clone())?; 230 | let api_token = mailbox.api_token.clone().map(value_or_file).transpose()?; 231 | if api_token.as_ref().is_some_and(|x| x.len() < 16) { 232 | bail!("Failed to provision mailbox '{name}': API tokens must be at least 16 characters long"); 233 | } 234 | let mut query = QueryBuilder::new( 235 | "INSERT INTO mailboxes (address, domain, password_hash, api_token, active, owner, provisioned)", 236 | ); 237 | query.push(" VALUES ("); 238 | query.push_bind(name); 239 | query.push(", "); 240 | query.push_bind(domain); 241 | query.push(", "); 242 | query.push_bind(&password_hash); 243 | query.push(", "); 244 | query.push_bind(&api_token); 245 | query.push(", "); 246 | query.push_bind(mailbox.active); 247 | query.push(", "); 248 | query.push_bind(&mailbox.owner); 249 | query.push(", TRUE)"); 250 | 251 | query.push(" ON CONFLICT (address) DO UPDATE SET"); 252 | query.push(" password_hash = "); 253 | query.push_bind(&password_hash); 254 | query.push(", api_token = "); 255 | query.push_bind(&api_token); 256 | query.push(", active = "); 257 | query.push_bind(mailbox.active); 258 | query.push(", owner = "); 259 | query.push_bind(&mailbox.owner); 260 | query.push(", provisioned = TRUE"); 261 | 262 | query.build().execute(pool).await?; 263 | } 264 | 265 | Ok(()) 266 | } 267 | 268 | pub async fn provision_aliases(pool: &SqlitePool, state: &State) -> Result<()> { 269 | let known_aliases = select_provisioned(pool, "aliases", "address").await?; 270 | let orphaned_aliases = &known_aliases - &state.aliases.keys().cloned().collect::>(); 271 | 272 | log::info!( 273 | "Provisioning {} aliases ({}, {})", 274 | state.aliases.len().yellow(), 275 | format!("-{}", orphaned_aliases.len()).red(), 276 | format!( 277 | "+{}", 278 | state.aliases.len() - known_aliases.len() + orphaned_aliases.len() 279 | ) 280 | .green(), 281 | ); 282 | delete_orphans(pool, "aliases", "address", &orphaned_aliases).await?; 283 | 284 | for (name, alias) in &state.aliases { 285 | let Some((_localpart, domain)) = name.split_once('@') else { 286 | bail!("Failed to provision alias '{name}': Invalid address"); 287 | }; 288 | 289 | if !state.domains.contains_key(domain) { 290 | bail!("Failed to provision alias '{name}': Domain '{domain}' must be a provisioned domain"); 291 | } 292 | if !state.users.contains_key(&alias.owner) && !state.mailboxes.contains_key(&alias.owner) { 293 | bail!( 294 | "Failed to provision alias '{name}': Owner '{}' must be a provisioned user or mailbox", 295 | alias.owner 296 | ); 297 | } 298 | 299 | let comment = alias.comment.as_deref().unwrap_or(""); 300 | let mut query = 301 | QueryBuilder::new("INSERT INTO aliases (address, domain, target, comment, active, owner, provisioned)"); 302 | 303 | query.push(" VALUES ("); 304 | query.push_bind(name); 305 | query.push(", "); 306 | query.push_bind(domain); 307 | query.push(", "); 308 | query.push_bind(&alias.target); 309 | query.push(", "); 310 | query.push_bind(comment); 311 | query.push(", "); 312 | query.push_bind(alias.active); 313 | query.push(", "); 314 | query.push_bind(&alias.owner); 315 | query.push(", TRUE)"); 316 | 317 | query.push(" ON CONFLICT (address) DO UPDATE SET"); 318 | query.push(" target = "); 319 | query.push_bind(&alias.target); 320 | query.push(", comment = "); 321 | query.push_bind(comment); 322 | query.push(", active = "); 323 | query.push_bind(alias.active); 324 | query.push(", owner = "); 325 | query.push_bind(&alias.owner); 326 | query.push(", provisioned = TRUE"); 327 | 328 | query.build().execute(pool).await?; 329 | } 330 | 331 | Ok(()) 332 | } 333 | 334 | pub async fn provision(pool: &SqlitePool) -> Result<()> { 335 | let Ok(provision_file) = std::env::var("IDMAIL_PROVISION") else { 336 | // No provisioning desired 337 | return Ok(()); 338 | }; 339 | 340 | let file_content = std::fs::read_to_string(&provision_file) 341 | .context(format!("Failed to read provision file: {}", provision_file))?; 342 | let state: State = toml::from_str(&file_content).context("Failed to parse provision state")?; 343 | 344 | provision_users(pool, &state).await?; 345 | provision_domains(pool, &state).await?; 346 | provision_mailboxes(pool, &state).await?; 347 | provision_aliases(pool, &state).await?; 348 | 349 | Ok(()) 350 | } 351 | -------------------------------------------------------------------------------- /src/auth.rs: -------------------------------------------------------------------------------- 1 | use leptos::*; 2 | use leptos_icons::Icon; 3 | use leptos_router::{ActionForm, Redirect}; 4 | use leptos_use::ColorMode; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use crate::utils::ColorModeToggle; 8 | 9 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 10 | #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] 11 | pub struct User { 12 | /// The username / mailbox address 13 | pub username: String, 14 | /// The associated password hash 15 | pub password_hash: String, 16 | /// The owner of this mailbox, or None if this is not a mailbox account 17 | pub mailbox_owner: Option, 18 | /// Whether the user is an admin 19 | pub admin: bool, 20 | /// Whether the user is active 21 | pub active: bool, 22 | } 23 | 24 | #[cfg(feature = "ssr")] 25 | pub mod ssr { 26 | pub use super::User; 27 | use anyhow::{anyhow, Context}; 28 | pub use axum_session_auth::{Authentication, HasPermission}; 29 | pub use axum_session_sqlx::SessionSqlitePool; 30 | pub use sqlx::SqlitePool; 31 | pub use std::collections::HashSet; 32 | pub type AuthSession = axum_session_auth::AuthSession; 33 | pub use async_trait::async_trait; 34 | 35 | impl User { 36 | pub async fn get(username: &str, pool: &SqlitePool) -> Option { 37 | let user = sqlx::query_as::<_, User>( 38 | "SELECT username, password_hash, NULL AS mailbox_owner, admin, active \ 39 | FROM users WHERE username = $1 \ 40 | UNION SELECT address AS username, password_hash, owner AS mailbox_owner, FALSE AS admin, active \ 41 | FROM mailboxes WHERE address = $1", 42 | ) 43 | .bind(username) 44 | .fetch_one(pool) 45 | .await 46 | .ok()?; 47 | 48 | Some(user) 49 | } 50 | 51 | pub async fn get_by_api_token(api_token: &str, pool: &SqlitePool) -> Option { 52 | if api_token.len() < 16 { 53 | // Disregard insecure API tokens directly 54 | return None; 55 | } 56 | 57 | let user = sqlx::query_as::<_, User>( 58 | "SELECT address AS username, password_hash, owner AS mailbox_owner, FALSE AS admin, active \ 59 | FROM mailboxes WHERE api_token = $1", 60 | ) 61 | .bind(api_token) 62 | .fetch_one(pool) 63 | .await 64 | .ok()?; 65 | 66 | if !user.active { 67 | log::warn!( 68 | "denying successful api token because user '{}' is inactive", 69 | user.username 70 | ); 71 | return None; 72 | } 73 | 74 | Some(user) 75 | } 76 | } 77 | 78 | #[async_trait] 79 | impl Authentication for User { 80 | async fn load_user(username: String, pool: Option<&SqlitePool>) -> Result { 81 | let pool = pool.context("Missing sql pool")?; 82 | 83 | User::get(&username, pool) 84 | .await 85 | .ok_or_else(|| anyhow!("Cannot get user")) 86 | } 87 | 88 | fn is_authenticated(&self) -> bool { 89 | true 90 | } 91 | 92 | fn is_active(&self) -> bool { 93 | self.active 94 | } 95 | 96 | fn is_anonymous(&self) -> bool { 97 | false 98 | } 99 | } 100 | 101 | #[async_trait] 102 | impl HasPermission for User { 103 | async fn has(&self, _perm: &str, _pool: &Option<&SqlitePool>) -> bool { 104 | false 105 | } 106 | } 107 | } 108 | 109 | #[server] 110 | pub async fn get_user() -> Result, ServerFnError> { 111 | let auth = crate::database::ssr::auth()?; 112 | Ok(auth.current_user) 113 | } 114 | 115 | /// Get the current user and ensure that it is an admin 116 | #[server] 117 | pub async fn auth_admin() -> Result { 118 | let user = get_user().await?.ok_or_else(|| ServerFnError::new("Unauthorized"))?; 119 | if !user.admin { 120 | return Err(ServerFnError::new("Unauthorized")); 121 | } 122 | 123 | Ok(user) 124 | } 125 | 126 | /// Get the current user and ensure that it is not a mailbox 127 | #[server] 128 | pub async fn auth_user() -> Result { 129 | let user = get_user().await?.ok_or_else(|| ServerFnError::new("Unauthorized"))?; 130 | if user.mailbox_owner.is_some() { 131 | return Err(ServerFnError::new("Unauthorized")); 132 | } 133 | 134 | Ok(user) 135 | } 136 | 137 | /// Get the current user, regardless of whether it is an admin, user or mailbox 138 | #[server] 139 | pub async fn auth_any() -> Result { 140 | get_user().await?.ok_or_else(|| ServerFnError::new("Unauthorized")) 141 | } 142 | 143 | #[server] 144 | pub async fn authenticate_user(username: String, password: String) -> Result { 145 | use argon2::{ 146 | password_hash::{PasswordHash, PasswordVerifier}, 147 | Argon2, 148 | }; 149 | 150 | // A generic error message to not leak information to the clients 151 | let generic_err = || ServerFnError::new("Wrong password or invalid user."); 152 | 153 | let pool = crate::database::ssr::pool()?; 154 | let user = User::get(&username, &pool).await.ok_or_else(generic_err)?; 155 | 156 | let verify_result = PasswordHash::new(&user.password_hash) 157 | .and_then(|hash| Argon2::default().verify_password(password.as_bytes(), &hash)); 158 | if verify_result.is_ok() { 159 | if !user.active { 160 | log::warn!("denying successful login attempt because user '{username}' is inactive"); 161 | return Err(generic_err()); 162 | } 163 | 164 | log::info!("login successful for user '{username}'"); 165 | Ok(user) 166 | } else { 167 | log::warn!( 168 | "failed authentication of user '{username}': {}", 169 | verify_result.unwrap_err() 170 | ); 171 | Err(generic_err()) 172 | } 173 | } 174 | 175 | #[server] 176 | pub async fn login(username: String, password: String) -> Result<(), ServerFnError> { 177 | let user = authenticate_user(username.clone(), password.clone()).await?; 178 | let auth = crate::database::ssr::auth()?; 179 | 180 | auth.login_user(user.username); 181 | auth.remember_user(false); 182 | leptos_axum::redirect("/"); 183 | Ok(()) 184 | } 185 | 186 | #[server] 187 | pub async fn logout() -> Result<(), ServerFnError> { 188 | let auth = crate::database::ssr::auth()?; 189 | auth.logout_user(); 190 | leptos_axum::redirect("/"); 191 | Ok(()) 192 | } 193 | 194 | #[component] 195 | pub fn Login( 196 | action: Action>, 197 | color_mode: Signal, 198 | set_color_mode: WriteSignal, 199 | ) -> impl IntoView { 200 | let action_value = Signal::derive(move || action.value().get().unwrap_or(Ok(()))); 201 | 202 | view! { 203 |
204 |
205 | 206 |
207 |
208 | 209 |
210 |
211 | 212 |

idmail

213 |
214 | 218 |
219 |

Login

220 |

221 | "Enter your mailbox address and password below to login" 222 |

223 |
224 |
225 |
226 |
227 | 233 | 240 |
241 |
242 |
243 | 249 |
250 | 256 |
257 | 260 |
261 | 265 |
266 |
267 |

268 | {move || { 269 | errors 270 | .get() 271 | .into_iter() 272 | .map(|(_, e)| view! { {e.to_string()} }) 273 | .collect_view() 274 | }} 275 | 276 |

277 |
278 |
279 | } 280 | }> 281 | 282 | {action_value} 283 | 284 | 291 |
292 |
293 | 294 |
295 |
296 | 297 | } 298 | } 299 | 300 | #[component] 301 | pub fn LoginView( 302 | login: Action>, 303 | logout: Action>, 304 | color_mode: Signal, 305 | set_color_mode: WriteSignal, 306 | ) -> impl IntoView { 307 | let user = create_resource( 308 | move || (login.version().get(), logout.version().get()), 309 | move |_| get_user(), 310 | ); 311 | 312 | view! { 313 | "Loading..." } 315 | }> 316 | {move || { 317 | user.get() 318 | .map(|user| match user { 319 | Err(e) => { 320 | view! { 321 |
322 | {format!("Login error: {}", e)} 323 |
324 | 325 | } 326 | .into_view() 327 | } 328 | Ok(None) => view! { }.into_view(), 329 | Ok(Some(_)) => view! { }.into_view(), 330 | }) 331 | }} 332 | 333 |
334 | } 335 | } 336 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Local, Utc}; 2 | use leptos::{ 3 | html::{Dialog, Select}, 4 | *, 5 | }; 6 | use leptos_icons::Icon; 7 | use leptos_struct_table::*; 8 | use leptos_use::ColorMode; 9 | 10 | #[component] 11 | #[allow(unused_variables, non_snake_case)] 12 | pub fn TimediffRenderer( 13 | class: String, 14 | #[prop(into)] value: MaybeSignal>, 15 | on_change: F, 16 | index: usize, 17 | ) -> impl IntoView 18 | where 19 | F: Fn(DateTime) + 'static, 20 | { 21 | let time_tooltip = move || { 22 | let utc_time = value(); 23 | let dt = utc_time - Utc::now(); 24 | let human_time = chrono_humanize::HumanTime::from(dt); 25 | 26 | let local_time: DateTime = DateTime::from(utc_time); 27 | let approximate_time = human_time.to_string(); 28 | let precise_time = local_time.format("%c").to_string(); 29 | 30 | view! { 31 |
32 | 33 | {precise_time} 34 | 35 | {approximate_time} 36 |
37 | } 38 | }; 39 | 40 | view! { {time_tooltip} } 41 | } 42 | 43 | #[component] 44 | #[allow(unused_variables, non_snake_case)] 45 | pub fn SliderRenderer( 46 | class: String, 47 | #[prop(into)] value: MaybeSignal, 48 | on_change: F, 49 | index: usize, 50 | ) -> impl IntoView 51 | where 52 | F: Fn(bool) + 'static, 53 | { 54 | view! { 55 | 56 | 68 | 69 | } 70 | } 71 | 72 | #[component] 73 | pub fn THeadCellRenderer( 74 | /// The class attribute for the head element. Generated by the classes provider. 75 | #[prop(into)] 76 | class: Signal, 77 | /// The class attribute for the inner element. Generated by the classes provider. 78 | #[prop(into)] 79 | inner_class: String, 80 | /// The index of the column. Starts at 0 for the first column. The order of the columns is the same as the order of the fields in the struct. 81 | index: usize, 82 | /// The sort priority of the column. `None` if the column is not sorted. `0` means the column is the primary sort column. 83 | #[prop(into)] 84 | sort_priority: Signal>, 85 | /// The sort direction of the column. See [`ColumnSort`]. 86 | #[prop(into)] 87 | sort_direction: Signal, 88 | /// The event handler for the click event. Has to be called with [`TableHeadEvent`]. 89 | on_click: F, 90 | children: Children, 91 | ) -> impl IntoView 92 | where 93 | F: Fn(TableHeadEvent) + 'static, 94 | { 95 | view! { 96 | 103 | 104 | 120 | 121 | } 122 | } 123 | 124 | #[derive(Clone, Copy)] 125 | pub struct TailwindClassesPreset; 126 | 127 | impl TableClassesProvider for TailwindClassesPreset { 128 | fn new() -> Self { 129 | Self 130 | } 131 | 132 | fn thead_row(&self, template_classes: &str) -> String { 133 | template_classes.to_string() 134 | } 135 | 136 | fn thead_cell(&self, _sort: ColumnSort, template_classes: &str) -> String { 137 | format!( 138 | "h-14 px-4 text-left text-base align-middle font-medium {}", 139 | template_classes 140 | ) 141 | } 142 | 143 | fn thead_cell_inner(&self) -> String { 144 | "flex items-center".to_string() 145 | } 146 | 147 | fn row(&self, row_index: usize, _selected: bool, template_classes: &str) -> String { 148 | let bg_color = if row_index % 2 == 0 { 149 | "bg-white dark:bg-black hover:bg-gray-100 dark:hover:bg-zinc-900" 150 | } else { 151 | "bg-gray-50 dark:bg-zinc-950 dark:bg-black hover:bg-gray-100 dark:hover:bg-zinc-900" 152 | }; 153 | 154 | format!( 155 | "border-t-[1.5px] border-gray-200 dark:border-zinc-800 last:border-0 {} {}", 156 | bg_color, template_classes 157 | ) 158 | } 159 | 160 | fn loading_cell(&self, _row_index: usize, _col_index: usize, prop_class: &str) -> String { 161 | format!("px-4 py-2 {}", prop_class) 162 | } 163 | 164 | fn loading_cell_inner(&self, _row_index: usize, _col_index: usize, prop_class: &str) -> String { 165 | format!( 166 | "animate-pulse h-2 bg-gray-400 rounded-full inline-block align-middle w-[calc(60%-2.5rem)] {}", 167 | prop_class 168 | ) 169 | } 170 | 171 | fn cell(&self, template_classes: &str) -> String { 172 | format!("px-4 py-2 whitespace-nowrap text-ellipsis {}", template_classes) 173 | } 174 | } 175 | 176 | #[component] 177 | pub fn Modal(#[prop(into)] open: Signal, children: Children, dialog_el: NodeRef) -> impl IntoView { 178 | create_effect(move |_| { 179 | if let Some(dialog) = dialog_el.get_untracked() { 180 | if open() { 181 | if dialog.show_modal().is_err() { 182 | dialog.set_open(true); 183 | } 184 | } else { 185 | dialog.close(); 186 | } 187 | } 188 | }); 189 | 190 | view! { 191 | 196 |
{children()}
197 |
198 | } 199 | } 200 | 201 | #[component] 202 | pub fn Select( 203 | #[prop(into, optional)] class: Option, 204 | #[prop(into, optional)] option_class: MaybeSignal, 205 | choices: ReadSignal>, 206 | value: ReadSignal, 207 | set_value: WriteSignal, 208 | ) -> impl IntoView { 209 | let select_el = create_node_ref:: 226 | 227 | 228 | 229 | 230 | 231 | } 232 | } 233 | 234 | #[component] 235 | pub fn SelectOption( 236 | #[prop(into, optional)] class: MaybeSignal, 237 | id: String, 238 | value: ReadSignal, 239 | ) -> impl IntoView { 240 | let id_copy = id.clone(); 241 | view! { 242 | 245 | } 246 | } 247 | 248 | #[component] 249 | pub fn EditModal &str + 'static>( 250 | #[prop(into)] data: RwSignal>>, 251 | #[prop(into)] errors: Signal>, 252 | what: String, 253 | get_title: F, 254 | #[prop(into)] on_confirm: Callback<(Option, Callback)>, 255 | children: Children, 256 | ) -> impl IntoView { 257 | let (server_error, set_server_error) = create_signal(None); 258 | let (modal_waiting, set_modal_waiting) = create_signal(false); 259 | let modal_elem = create_node_ref::(); 260 | let open = Signal::derive(move || data.get().is_some()); 261 | 262 | let on_error = Callback::new(move |error: String| { 263 | set_modal_waiting(false); 264 | set_server_error(Some(error)); 265 | }); 266 | 267 | create_effect(move |_| { 268 | if !open() { 269 | set_modal_waiting(false); 270 | set_server_error(None); 271 | } 272 | }); 273 | 274 | view! { 275 | 276 |
277 |

278 | {move || { 279 | if let Some(Some(data)) = data.get() { 280 | format!("Edit {}", get_title(&data)) 281 | } else { 282 | format!("New {}", what) 283 | } 284 | }} 285 | 286 |

287 |
288 | {children()} 289 |
290 |
291 | 292 |
293 |
294 | 295 |

{child.clone()}

296 |
297 | {move || { 298 | match server_error() { 299 | None => view! {}.into_view(), 300 | Some(error) => view! {

{error}

}.into_view(), 301 | } 302 | }} 303 | 304 |
305 |
306 |
307 | 317 | 337 |
338 |
339 |
340 |
341 | } 342 | } 343 | 344 | #[component] 345 | pub fn DeleteModal( 346 | #[prop(into)] data: RwSignal>, 347 | #[prop(into)] text: View, 348 | #[prop(into)] on_confirm: Callback, 349 | ) -> impl IntoView { 350 | let (modal_waiting, set_modal_waiting) = create_signal(false); 351 | let modal_elem = create_node_ref::(); 352 | let open = Signal::derive(move || data.get().is_some()); 353 | 354 | create_effect(move |_| { 355 | if !open() { 356 | set_modal_waiting(false); 357 | } 358 | }); 359 | 360 | view! { 361 | 362 |
363 |
364 |
365 |
366 | 367 |
368 |
369 |

370 | "Delete " {data} 371 |

372 |
373 |

{text}

374 |
375 |
376 |
377 |
378 |
379 | 389 | 409 |
410 |
411 |
412 | } 413 | } 414 | 415 | #[component] 416 | pub fn ColorModeToggle(color_mode: Signal, set_color_mode: WriteSignal) -> impl IntoView { 417 | view! { 418 | 441 | } 442 | } 443 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [Installation](#-installation) \| [Building](#-building) \| [API Endpoints](#%EF%B8%8F-api-endpoints) \| [Stalwart configuration](#%EF%B8%8F-stalwart-configuration) \| [Provisioning](#-provisioning) 2 | 3 |

4 | 5 | 6 |

7 | 8 | > [!IMPORTANT] 9 | > Sent and recv counts require MTA-specific hook setups that are currently not documented! 10 | 11 | ## 📧 idmail 12 | 13 | Idmail is an email alias and account management interface for self-hosted mailservers, 14 | which you can use to hide your true email address from online services. 15 | This is NOT an email forwarding service like [addy.io](https://addy.io/)! Idmail is a frontend 16 | to a sqlite database which contains a table of mailboxes and aliases to be consumed by 17 | a mailsever like [Stalwart](https://stalw.art/), [maddy](https://maddy.email/), [Postfix](https://www.postfix.org/) or others. 18 | The following features are available: 19 | 20 | - 🧑,🌐 Manage user accounts and domains (as an admin) 21 | - 📫,🕵️ Manage mailboxes and aliases (per user) 22 | - 🔄 Generate random aliases 23 | - 🔑 API endpoint allows integration with password managers (Bitwarden, ...) 24 | - 📈 Track sent/received statistics per alias 25 | - 🌌 Per-domain catch-all 26 | - 🌟 Provisioning support 27 | 28 | If you login with a mailbox account, you can change the mailbox password and manage its aliases. 29 | Mailbox accounts can use the API to create new aliases with the API token from their settings page. 30 | Logging in with a user account (these have no `@domain.tld` suffix), you can additionally create new mailboxes 31 | and manage any domains assigned to you by an admin. 32 | 33 | You will have to integrate this with a mailserver that supports querying an sqlite database 34 | for mailbox accounts and aliases. We recommend using [Stalwart](https://stalw.art/) and provide the necessary queries 35 | for it, but any other server will work fine if you adjust the queries accordingly. 36 | 37 | ## 🚀 Installation 38 | 39 | #### ❓ Other distributions 40 | 41 | Refer to the second part of the [Building](#-building) section for details 42 | on how to build and deploy this application. 43 | 44 | #### ❄️ NixOS 45 | 46 | Installation under NixOS is straightforward. This repository provides an overlay and NixOS module for 47 | simple deployment. First, add this repository flake input and import the module on your NixOS system(s): 48 | 49 | ```nix 50 | { 51 | inputs = { 52 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 53 | idmail.url = "github:oddlama/idmail"; 54 | idmail.inputs.nixpkgs.follows = "nixpkgs"; 55 | }; 56 | 57 | outputs = { self, nixpkgs, idmail }: { 58 | # Add the module to your system(s) 59 | nixosConfigurations.yourhostname = nixpkgs.lib.nixosSystem { 60 | system = "x86_64-linux"; 61 | modules = [ 62 | ./configuration.nix 63 | idmail.nixosModules.default 64 | ]; 65 | }; 66 | }; 67 | } 68 | ``` 69 | 70 | Afterwards, simply enable the service in your configuration and proxy the web interface: 71 | 72 | ```nix 73 | { 74 | services.idmail.enable = true; 75 | 76 | services.nginx.virtualHosts."alias.example.com" = { 77 | forceSSL = true; 78 | locations."/" = { 79 | proxyPass = "http://localhost:3000"; 80 | proxyWebsockets = true; 81 | }; 82 | }; 83 | } 84 | ``` 85 | 86 | The database will be available under `/var/lib/idmail/idmail.db` for consumption by other services, 87 | the service listens on `127.0.0.1:3000` by default. The example above uses nginx to reverse proxy the application. 88 | If the admin user was not provisioned, it will be recovered on start and a generated password will be printed to the journal. 89 | 90 | You can provision anything by using the `services.idmail.provision` configuration. See [Provisioning](#-provisioning) 91 | or view the module source for more information. 92 | 93 | ```nix 94 | { 95 | services.idmail.provision = { 96 | enable = true; 97 | users.admin = { 98 | admin = true; 99 | # Generate hash with `echo -n "password" | nix run nixpkgs#libargon2 -- somerandomsalt -id` 100 | password_hash = "$argon2id$v=19$m=4096,t=3,p=1$YXJnbGluYXJsZ2luMjRvaQ$DXdfVNRSFS1QSvJo7OmXIhAYYtT/D92Ku16DiJwxn8U"; 101 | #password_hash = "%{file:/path/to/secret}%"; # Or read a file at runtime 102 | }; 103 | domains."example.com" = { 104 | owner = "admin"; 105 | public = true; 106 | }; 107 | mailboxes."me@example.com" = { 108 | password_hash = "$argon2id$v=19$m=4096,t=3,p=1$YXJnbGluYXJsZ2luMjRvaQ$fiD9Bp3KidVI/E+mGudu6+h9XmF9TU9Bx4VGX0PniDE"; 109 | owner = "admin"; 110 | #api_token = "VC0lZ6O49nfxU4oK0KbahlSMsqBFiHyYFGUQvzzki6ky5mSM"; # Please don't hardcode api tokens 111 | api_token = "%{file:/path/to/secret}%"; 112 | }; 113 | aliases."somealias@example.com" = { 114 | target = "me@example.com"; 115 | owner = "me@example.com"; 116 | comment = "Used for xyz"; 117 | }; 118 | }; 119 | } 120 | ``` 121 | 122 | For a working in-the-field configuration, feel free to have a look at my personal repository. 123 | Specifically [stalwart-mail.nix](https://github.com/oddlama/nix-config/blob/main/hosts/envoy/stalwart-mail.nix) 124 | and [idmail.nix](https://github.com/oddlama/nix-config/blob/main/hosts/envoy/idmail.nix). 125 | 126 | ## 🧰 Building 127 | 128 | This project is made to be build via nix. If you have nix installed, 129 | the project can be built simply by running: 130 | 131 | ```bash 132 | nix build github:oddlama/idmail 133 | ``` 134 | 135 | If you want to build it yourself instead, you can do so by executing: 136 | 137 | ```bash 138 | export RUSTFLAGS="--cfg=web_sys_unstable_apis" 139 | export LEPTOS_ENV="PROD" 140 | cargo leptos build --release -vvv 141 | ``` 142 | 143 | You can then run the server like this: 144 | 145 | ``` 146 | export LEPTOS_SITE_ADDR="0.0.0.0:3000" # only if you want to change listen address or port 147 | ./target/release/idmail 148 | ``` 149 | 150 | You can host binary in any way you prefer (Docker, systemd services, ...). 151 | Afterwards, configure your mailserver to utilize the database for lookups ([see Stalwart configuration](#%EF%B8%8F-stalwart-configuration)) 152 | and optionally configure your password manager to use one of the provided [API Endpoints](#%EF%B8%8F-api-endpoints). 153 | If the admin user doesn't exist on start, it will be recovered and a generated password will be printed to stdout. 154 | 155 | ## ☁️ API Endpoints 156 | 157 | API endpoints are provided which allow you to generate random aliases, 158 | compatible with those provided by [addy.io (AnonAddy)](https://addy.io/) or [SimpleLogin](https://simplelogin.io/). 159 | This means you can use it with a password manager to automatically create aliases for your logins. 160 | Aliases will be generated via the [`faker_rand` Username](https://docs.rs/faker_rand/latest/faker_rand/en_us/internet/struct.Username.html) generator, 161 | and may produce the following results: 162 | 163 |
164 | Example of generated email addresses 165 | 166 | ``` 167 | ycrona62@example.com 168 | eunicecole@example.com 169 | hschulist@example.com 170 | rwalter25@example.com 171 | ydach15@example.com 172 | pansywisozk@example.com 173 | uroob30@example.com 174 | earlinebayer@example.com 175 | zhoppe26@example.com 176 | lauramayert@example.com 177 | quinnnitzsche@example.com 178 | whauck98@example.com 179 | iglover5@example.com 180 | stancollins@example.com 181 | fchamplin08@example.com 182 | bmurphy2@example.com 183 | ywelch4@example.com 184 | erolfson@example.com 185 | ldicki2@example.com 186 | margarettlueilwitz@example.com 187 | eusebioernser@example.com 188 | clynch@example.com 189 | seanoberbrunner@example.com 190 | arielstiedemann@example.com 191 | zhamill3@example.com 192 | clueilwitz76@example.com 193 | bonitajenkins@example.com 194 | leannsanford@example.com 195 | vkirlin50@example.com 196 | bobernier@example.com 197 | jazminbeatty@example.com 198 | ``` 199 | 200 |
201 | 202 | There are two different API endpoints available: 203 | 204 | - addy.io compatible: Allows you to select a domain. A random avaliable domain is selected by the server if left empty or filled with the special value `random`. 205 | - SimpleLogin compatible: Does not allow selecting a domain, so a random available domain is always selected 206 | 207 | Both endpoints always generate the same random usernames and ignore any format options in case the original API provides those. 208 | The required API token can be generated on the settings page when logging into the Web interface as a mailbox account. 209 | 210 |
211 | 212 | 213 | #### addy.io compatible endpoint 214 | 215 | 216 | 217 | - Url: `https://idmail.example.com/api/v1/aliases` 218 | - Method: `POST` 219 | - Token: Via header `Authorization: Bearer {token}` 220 | - Success: `201` 221 | 222 | - In Bitwarden, just use `idmail.example.com` as the endpoint. 223 | 224 |
225 | Example request and response (curl) 226 | 227 | Request: 228 | 229 | ``` 230 | curl -X POST \ 231 | -H "Content-Type: application/json" \ 232 | -H "Accept: application/json" \ 233 | -H "Authorization: Bearer {token}" \ 234 | --data '{"domain":"example.com","description":"An optional comment added to the entry"}' \ 235 | localhost:3000/api/v1/aliases 236 | ``` 237 | 238 | Response: 239 | 240 | ```json 241 | { 242 | "data": { 243 | "active": true, 244 | "aliasable_id": null, 245 | "aliasable_type": null, 246 | "created_at": "2000-01-01 00:00:00", 247 | "deleted_at": null, 248 | "description": "An optional comment added to the entry", 249 | "domain": "example.com", 250 | "email": "zhoppe26@example.com", 251 | "emails_blocked": 0, 252 | "emails_forwarded": 0, 253 | "emails_replied": 0, 254 | "emails_sent": 0, 255 | "extension": null, 256 | "from_name": null, 257 | "id": "00000000-0000-0000-0000-000000000000", 258 | "last_blocked": null, 259 | "last_forwarded": "2000-01-01 00:00:00", 260 | "last_replied": null, 261 | "last_sent": null, 262 | "local_part": "00000000-0000-0000-0000-000000000000", 263 | "recipients": [], 264 | "updated_at": "2000-01-01 00:00:00", 265 | "user_id": "00000000-0000-0000-0000-000000000000" 266 | } 267 | } 268 | ``` 269 | 270 |
271 |
272 | 273 |
274 | 275 | 276 | #### SimpleLogin compatible endpoint 277 | 278 | 279 | 280 | - Url: `https://idmail.example.com/api/alias/random/new` 281 | - Method: `POST` 282 | - Token: Via header `Authorization: {token}` 283 | - Success: `201` 284 | 285 | - In Bitwarden, just use `idmail.example.com` as the endpoint. 286 | 287 |
288 | Example request and response (curl) 289 | 290 | Request: 291 | 292 | ``` 293 | curl -X POST \ 294 | -H "Content-Type: application/json" \ 295 | -H "Accept: application/json" \ 296 | -H "Authorization: {token}" \ 297 | --data '{"note":"A comment added to the entry"}' \ 298 | localhost:3000/api/alias/random/new 299 | ``` 300 | 301 | Response: 302 | 303 | ```json 304 | { 305 | "alias": "zhoppe26@example.com" 306 | } 307 | ``` 308 | 309 |
310 |
311 | 312 | ## ⛔ Reserved addresses 313 | 314 | For security purposes, we always reserve a list of special mailbox/alias names which only the domain owner (or admin) may create. 315 | The list currently contains: 316 | 317 | ``` 318 | abuse 319 | admin 320 | hostmaster 321 | info 322 | no-reply 323 | postmaster 324 | root 325 | security 326 | support 327 | webmaster 328 | ``` 329 | 330 | > [!WARNING] 331 | > Never use an admin account to create mailboxes for other people, as it allows 332 | > them to use these reserved addresses! (if the mailbox is owner is the domain owner) 333 | 334 | ## ⚙️ Stalwart configuration 335 | 336 | To integrate the idmail sqlite database with your stalwart server, you will need to provide 337 | the necessary SQL queries to stalwart. This is done by configuring and external directory 338 | in stalwart. This requires some complex queries to honor the `active` flag correctly and 339 | output everything in the format stalwart expects. 340 | 341 | You have to make sure that stalwart has read-write access to the `idmail.db` database file and the related files for sqlite WAL mode. 342 | Here's the configuration that you will need (don't forget to adjust the path to the `idmail.db` database): 343 | 344 |
345 | Example stalwart configuration 346 | 347 | ```toml 348 | [storage] 349 | directory = "idmail" 350 | 351 | [directory.idmail] 352 | type = "sql" 353 | store = "idmail" 354 | 355 | [directory.idmail.columns] 356 | name = "name" 357 | description = "description" 358 | secret = "secret" 359 | email = "email" 360 | # quotas are currently not implemented in idmail 361 | #quota = "quota" 362 | class = "type" 363 | 364 | [store.idmail] 365 | # TODO: adjust the path below! 366 | path = "/path/to/idmail.db" 367 | type = "sqlite" 368 | 369 | [store.idmail.query] 370 | emails = """\ 371 | SELECT address FROM ( \ 372 | SELECT m.address AS address, 1 AS rowOrder \ 373 | FROM mailboxes AS m \ 374 | JOIN domains AS d ON m.domain = d.domain \ 375 | JOIN users AS u ON m.owner = u.username \ 376 | WHERE m.address = ?1 AND m.active = true AND d.active = true AND u.active = true \ 377 | UNION SELECT a.address AS address, 2 AS rowOrder \ 378 | FROM aliases AS a \ 379 | JOIN domains AS d ON a.domain = d.domain \ 380 | JOIN ( \ 381 | SELECT username FROM users \ 382 | WHERE active = true \ 383 | UNION SELECT m.address AS username FROM mailboxes AS m \ 384 | JOIN users AS u ON m.owner = u.username \ 385 | WHERE m.active = true AND u.active = true \ 386 | ) AS u ON a.owner = u.username \ 387 | WHERE a.target = ?1 AND a.active = true AND d.active = true \ 388 | UNION SELECT ('@' || d.domain) AS address, 2 AS rowOrder FROM domains AS d \ 389 | JOIN mailboxes AS m ON d.catch_all = m.address \ 390 | JOIN users AS u ON m.owner = u.username \ 391 | WHERE d.catch_all = ?1 AND d.active = true AND m.active = true AND u.active = true \ 392 | ORDER BY rowOrder, address ASC \ 393 | ) \ 394 | """ 395 | members = "" 396 | name = """\ 397 | SELECT m.address AS name, 'individual' AS type, m.password_hash AS secret, m.address AS description, 0 AS quota FROM mailboxes AS m \ 398 | JOIN domains AS d ON m.domain = d.domain \ 399 | JOIN users AS u ON m.owner = u.username \ 400 | WHERE m.address = ?1 AND m.active = true AND d.active = true AND u.active = true \ 401 | """ 402 | # the ordering allows aliases to override existing mailboxes. 403 | # The web interface never allows you to create such an alias, 404 | # but by provisioning you can create send-only mailboxes that 405 | # have their incoming mail redirected somewhere else 406 | recipients = """\ 407 | SELECT name FROM ( \ 408 | SELECT a.target AS name, 1 AS rowOrder AS name FROM aliases AS a \ 409 | JOIN domains AS d ON a.domain = d.domain \ 410 | JOIN ( \ 411 | SELECT username FROM users \ 412 | WHERE active = true \ 413 | UNION SELECT m.address AS username FROM mailboxes AS m \ 414 | JOIN users AS u ON m.owner = u.username \ 415 | WHERE m.active = true AND u.active = true \ 416 | ) AS u ON a.owner = u.username \ 417 | WHERE a.address = ?1 AND a.active = true AND d.active = true \ 418 | UNION SELECT m.address AS name, 2 AS rowOrder AS name FROM mailboxes AS m \ 419 | JOIN domains AS d ON m.domain = d.domain \ 420 | JOIN users AS u ON m.owner = u.username \ 421 | WHERE m.address = ?1 AND m.active = true AND d.active = true AND u.active = true \ 422 | UNION SELECT d.catch_all AS name, 3 AS rowOrder AS name FROM domains AS d \ 423 | JOIN mailboxes AS m ON d.catch_all = m.address \ 424 | JOIN users AS u ON m.owner = u.username \ 425 | WHERE ?1 = ('@' || d.domain) AND d.active = true AND m.active = true AND u.active = true \ 426 | ORDER BY rowOrder, name ASC \ 427 | LIMIT 1 \ 428 | ) \ 429 | """ 430 | ``` 431 |
432 | 433 | ## 🌟 Provisioning 434 | 435 | To support declarative deployment you can provision users, domains, mailboxes and aliases out of the box. 436 | This works by pointing the environment variable `IDMAIL_PROVISION` to a toml file containing the desired state. 437 | The application automatically tracks provisioned entities and ensures that they will automatically be removed 438 | again if you remove them from the state file, without touching entities that were created dynamically by you our your users. 439 | This will *not* cascade deletion, so removing a domain will not touch any dependent aliases or mailboxes. The mailserver queries 440 | should always validate combinations by joining the appropriate tables. 441 | 442 | The state file has the format shown below: 443 | 444 | ```toml 445 | [users."username"] 446 | # Password hash, should be a argon2id hash. 447 | # Can be generated with: `echo -n "whatever" | argon2 somerandomsalt -id` 448 | # Also accepts "%{file:/path/to/secret}%" to refer to the contents of a file. 449 | password_hash = "$argon2id$v=19$m=4096,t=3,p=1$YXJnbGluYXJsZ2luMjRvaQ$DXdfVNRSFS1QSvJo7OmXIhAYYtT/D92Ku16DiJwxn8U" 450 | # Whether the user should be an admin. 451 | # Optional, default: false 452 | admin = false 453 | # Whether the user should be active 454 | # Optional, default: true 455 | active = true 456 | 457 | [domains."example.com"] 458 | # The user which owns this domain. Allows that user to modify 459 | # the catch all address and the domain's active state. 460 | # Creation and deletion of any domain is always restricted to admins only. 461 | owner = "username" 462 | # A catch-all address for this domain. 463 | # Optional. Default: None 464 | catch_all = "postmaster@example.com" 465 | # Whether the domain should be available for use by any registered 466 | # user instead of just the owner. Admins can always use any domain, 467 | # regardless of this setting. 468 | # Optional, default: false 469 | public = false 470 | # Whether the domain should be active 471 | # Optional, default: true 472 | active = true 473 | 474 | [mailboxes."me@example.com"] 475 | # Password hash, should be a argon2id hash. 476 | # Can be generated with: `echo -n "whatever" | argon2 somerandomsalt -id` 477 | # Also accepts "%{file:/path/to/secret}%" to refer to the contents of a file. 478 | password_hash = "$argon2id$v=19$m=4096,t=3,p=1$YXJnbGluYXJsZ2luMjRvaQ$fiD9Bp3KidVI/E+mGudu6+h9XmF9TU9Bx4VGX0PniDE" 479 | # The user which owns this mailbox. That user has full control over the mailbox and its aliases. 480 | owner = "username" 481 | # An API token for this mailbox to allow alias creation via the API endpoints. 482 | # Optional. Default: None (API access disabled) 483 | # Minimum length 16. Must be unique! 484 | # Also accepts "%{file:/path/to/secret}%" to refer to the contents of a file. 485 | api_token = "VC0lZ6O49nfxU4oK0KbahlSMsqBFiHyYFGUQvzzki6ky5mSM" 486 | #api_token = "%{file:/path/to/secret}%" 487 | # Whether the mailbox should be active 488 | # Optional, default: true 489 | active = true 490 | 491 | [aliases."somealias@example.com"] 492 | # The target address for this alias. The WebUI restricts users to only 493 | # target mailboxes they own. Admins and this provisioning file 494 | # have no such restrictions. 495 | target = "me@example.com" 496 | # The user/mailbox which owns this alias. If owned by a mailbox, 497 | # the user owning the mailbox transitively owns this. 498 | owner = "me@example.com" 499 | # A comment to store alongside this alias. 500 | # Optional, default: None 501 | comment = "Used for xyz" 502 | # Whether the user should be active 503 | # Optional, default: true 504 | active = true 505 | ``` 506 | 507 | Small example which creates an admin user and one domain: 508 | 509 | ```toml 510 | [users.admin] 511 | password_hash = "$argon2id$v=19$m=4096,t=3,p=1$YXJnbGluYXJsZ2luMjRvaQ$DXdfVNRSFS1QSvJo7OmXIhAYYtT/D92Ku16DiJwxn8U" 512 | admin = true 513 | 514 | [domains."example.com"] 515 | owner = "admin" 516 | public = true 517 | ``` 518 | 519 | ## 📜 License 520 | 521 | Licensed under the MIT license ([LICENSE](LICENSE) or ). 522 | Unless you explicitly state otherwise, any contribution intentionally 523 | submitted for inclusion in this project by you, shall be licensed as above, without any additional terms or conditions. 524 | -------------------------------------------------------------------------------- /src/domains.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | use std::ops::Range; 3 | 4 | use crate::auth::User; 5 | use crate::utils::{DeleteModal, EditModal}; 6 | use crate::utils::{SliderRenderer, THeadCellRenderer, TailwindClassesPreset, TimediffRenderer}; 7 | 8 | use chrono::{DateTime, Utc}; 9 | use leptos::{ev::MouseEvent, logging::error, *}; 10 | use leptos_icons::Icon; 11 | use leptos_struct_table::*; 12 | use leptos_use::use_debounce_fn_with_arg; 13 | use serde::{Deserialize, Serialize}; 14 | #[cfg(feature = "ssr")] 15 | use sqlx::QueryBuilder; 16 | 17 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TableRow)] 18 | #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] 19 | #[table(sortable, classes_provider = TailwindClassesPreset, thead_cell_renderer = THeadCellRenderer)] 20 | pub struct Domain { 21 | #[table(class = "w-40")] 22 | pub domain: String, 23 | pub catch_all: Option, 24 | #[table(class = "w-1", renderer = "SliderRenderer")] 25 | pub public: bool, 26 | #[table(class = "w-1", renderer = "SliderRenderer")] 27 | pub active: bool, 28 | #[table(class = "w-1")] 29 | pub owner: String, 30 | #[table(class = "w-1", title = "Created", renderer = "TimediffRenderer")] 31 | pub created_at: DateTime, 32 | } 33 | 34 | #[derive(Clone, Debug, Serialize, Deserialize)] 35 | pub struct DomainQuery { 36 | #[serde(default)] 37 | sort: VecDeque<(usize, ColumnSort)>, 38 | range: Range, 39 | search: String, 40 | } 41 | 42 | #[server] 43 | pub async fn allowed_domains() -> Result, ServerFnError> { 44 | let user = crate::auth::auth_any().await?; 45 | 46 | let mut query = QueryBuilder::new("SELECT domain, owner FROM domains"); 47 | query.push(" WHERE active = TRUE AND (public = TRUE OR owner = "); 48 | query.push_bind(&user.username); 49 | if let Some(mailbox_owner) = user.mailbox_owner { 50 | query.push(" OR owner = "); 51 | query.push_bind(mailbox_owner.clone()); 52 | } 53 | query.push(")"); 54 | 55 | let pool = crate::database::ssr::pool()?; 56 | Ok(query.build_query_as::<(String, String)>().fetch_all(&pool).await?) 57 | } 58 | 59 | #[server] 60 | pub async fn list_domains(query: DomainQuery) -> Result, ServerFnError> { 61 | let user = crate::auth::auth_user().await?; 62 | 63 | let DomainQuery { sort, range, search } = query; 64 | 65 | let mut query = QueryBuilder::new("SELECT * FROM domains WHERE 1=1"); 66 | if !user.admin { 67 | query.push(" AND owner = "); 68 | query.push_bind(&user.username); 69 | } 70 | if !search.is_empty() { 71 | query.push(" AND ( domain LIKE concat('%', "); 72 | query.push_bind(&search); 73 | query.push(", '%') OR catch_all LIKE concat('%', "); 74 | query.push_bind(&search); 75 | query.push(", '%') OR owner LIKE concat('%', "); 76 | query.push_bind(&search); 77 | query.push(", '%') )"); 78 | } 79 | 80 | if let Some(order) = Domain::sorting_to_sql(&sort) { 81 | query.push(" "); 82 | query.push(order); 83 | } 84 | 85 | query.push(" LIMIT "); 86 | query.push_bind(range.len() as i64); 87 | query.push(" OFFSET "); 88 | query.push_bind(range.start as i64); 89 | 90 | let pool = crate::database::ssr::pool()?; 91 | Ok(query.build_query_as::().fetch_all(&pool).await?) 92 | } 93 | 94 | #[server] 95 | pub async fn domain_count() -> Result { 96 | let user = crate::auth::auth_user().await?; 97 | 98 | let mut query = QueryBuilder::new("SELECT COUNT(*) FROM domains"); 99 | if !user.admin { 100 | query.push(" WHERE owner = "); 101 | query.push_bind(&user.username); 102 | } 103 | 104 | let pool = crate::database::ssr::pool()?; 105 | let count = query.build_query_scalar::().fetch_one(&pool).await?; 106 | 107 | Ok(count as usize) 108 | } 109 | 110 | #[server] 111 | pub async fn delete_domain(domain: String) -> Result<(), ServerFnError> { 112 | // Creating/Deleting only as admin! 113 | let user = crate::auth::auth_admin().await?; 114 | 115 | let mut query = QueryBuilder::new("DELETE FROM domains WHERE domain = "); 116 | query.push_bind(domain); 117 | 118 | // (Hypothetical) Non-admins can only delete their own domains 119 | if !user.admin { 120 | query.push(" AND owner = "); 121 | query.push_bind(&user.username); 122 | } 123 | 124 | let pool = crate::database::ssr::pool()?; 125 | query.build().execute(&pool).await.map(|_| ())?; 126 | Ok(()) 127 | } 128 | 129 | #[server] 130 | pub async fn create_or_update_domain( 131 | old_domain: Option, 132 | domain: String, 133 | catch_all: String, 134 | public: bool, 135 | active: bool, 136 | owner: String, 137 | ) -> Result<(), ServerFnError> { 138 | let user = if old_domain.is_some() { 139 | // Editing is allowed for some users 140 | crate::auth::auth_user().await? 141 | } else { 142 | // Creation only as admin. 143 | crate::auth::auth_admin().await? 144 | }; 145 | let pool = crate::database::ssr::pool()?; 146 | 147 | // Only admins can assign other owners 148 | let owner = if user.admin { owner.trim() } else { &user.username }; 149 | // Empty owner -> self owned 150 | let owner = if owner.is_empty() { &user.username } else { owner }; 151 | // Only admins may create public domains 152 | let public = public && user.admin; 153 | if domain.is_empty() { 154 | return Err(ServerFnError::new("domain cannot be empty")); 155 | } 156 | 157 | if let Some(old_domain) = old_domain { 158 | let mut query = QueryBuilder::new("UPDATE domains SET catch_all = "); 159 | query.push_bind(catch_all); 160 | if user.admin { 161 | // Only admins can edit the domain itself 162 | query.push(", domain = "); 163 | query.push_bind(domain); 164 | } 165 | query.push(", public = "); 166 | query.push_bind(public); 167 | query.push(", active = "); 168 | query.push_bind(active); 169 | query.push(", owner = "); 170 | query.push_bind(owner); 171 | query.push(" WHERE domain = "); 172 | query.push_bind(old_domain); 173 | if !user.admin { 174 | query.push(" AND owner = "); 175 | query.push_bind(&user.username); 176 | } 177 | 178 | query.build().execute(&pool).await.map(|_| ())?; 179 | } else { 180 | sqlx::query("INSERT INTO domains (domain, catch_all, public, active, owner) VALUES (?, ?, ?, ?, ?)") 181 | .bind(domain) 182 | .bind(catch_all) 183 | .bind(public) 184 | .bind(active) 185 | .bind(owner) 186 | .execute(&pool) 187 | .await 188 | .map(|_| ())?; 189 | } 190 | 191 | Ok(()) 192 | } 193 | 194 | #[server] 195 | pub async fn update_domain_public_and_active(domain: String, public: bool, active: bool) -> Result<(), ServerFnError> { 196 | let user = crate::auth::auth_user().await?; 197 | 198 | // Only admins may create public domains 199 | let public = public && user.admin; 200 | 201 | let mut query = QueryBuilder::new("UPDATE domains SET public = "); 202 | query.push_bind(public); 203 | query.push(", active = "); 204 | query.push_bind(active); 205 | query.push(" WHERE domain = "); 206 | query.push_bind(domain); 207 | 208 | // Non-admins can only change their own domains 209 | if !user.admin { 210 | query.push(" AND owner = "); 211 | query.push_bind(&user.username); 212 | } 213 | 214 | let pool = crate::database::ssr::pool()?; 215 | query.build().execute(&pool).await.map(|_| ())?; 216 | Ok(()) 217 | } 218 | 219 | #[derive(Default)] 220 | pub struct DomainTableDataProvider { 221 | sort: VecDeque<(usize, ColumnSort)>, 222 | pub search: RwSignal, 223 | } 224 | 225 | impl TableDataProvider for DomainTableDataProvider { 226 | async fn get_rows(&self, range: Range) -> Result<(Vec, Range), String> { 227 | list_domains(DomainQuery { 228 | search: self.search.get_untracked().trim().to_string(), 229 | sort: self.sort.clone(), 230 | range: range.clone(), 231 | }) 232 | .await 233 | .map(|rows| { 234 | let len = rows.len(); 235 | (rows, range.start..range.start + len) 236 | }) 237 | .map_err(|e| format!("{e:?}")) 238 | } 239 | 240 | async fn row_count(&self) -> Option { 241 | domain_count().await.ok() 242 | } 243 | 244 | fn set_sorting(&mut self, sorting: &VecDeque<(usize, ColumnSort)>) { 245 | self.sort = sorting.clone(); 246 | } 247 | 248 | fn track(&self) { 249 | self.search.track(); 250 | } 251 | } 252 | 253 | #[component] 254 | pub fn Domains(user: User) -> impl IntoView { 255 | let mut rows = DomainTableDataProvider::default(); 256 | let default_sorting = VecDeque::from([(5, ColumnSort::Descending)]); 257 | rows.set_sorting(&default_sorting); 258 | let sorting = create_rw_signal(default_sorting); 259 | 260 | let reload_controller = ReloadController::default(); 261 | let on_input = use_debounce_fn_with_arg(move |value| rows.search.set(value), 300.0); 262 | let (count, set_count) = create_signal(0); 263 | 264 | let delete_modal_domain = create_rw_signal(None); 265 | let edit_modal_domain = create_rw_signal(None); 266 | 267 | let (edit_modal_input_domain, set_edit_modal_input_domain) = create_signal("".to_string()); 268 | let (edit_modal_input_catchall, set_edit_modal_input_catchall) = create_signal("".to_string()); 269 | let (edit_modal_input_public, set_edit_modal_input_public) = create_signal(true); 270 | let (edit_modal_input_active, set_edit_modal_input_active) = create_signal(true); 271 | let (edit_modal_input_owner, set_edit_modal_input_owner) = create_signal("".to_string()); 272 | let edit_modal_open_with = Callback::new(move |edit_domain: Option| { 273 | edit_modal_domain.set(Some(edit_domain.clone())); 274 | 275 | if let Some(edit_domain) = edit_domain { 276 | set_edit_modal_input_domain(edit_domain.domain.clone()); 277 | set_edit_modal_input_catchall(edit_domain.catch_all.unwrap_or("".to_string()).clone()); 278 | set_edit_modal_input_public(edit_domain.public); 279 | set_edit_modal_input_active(edit_domain.active); 280 | set_edit_modal_input_owner(edit_domain.owner.clone()); 281 | } else { 282 | set_edit_modal_input_domain("".to_string()); 283 | set_edit_modal_input_catchall("".to_string()); 284 | set_edit_modal_input_public(user.admin); 285 | set_edit_modal_input_active(true); 286 | set_edit_modal_input_owner("".to_string()); 287 | } 288 | }); 289 | 290 | let errors = Vec::new; 291 | 292 | let on_edit = move |(data, on_error): (Option, Callback)| { 293 | spawn_local(async move { 294 | if let Err(e) = create_or_update_domain( 295 | data.map(|x| x.domain), 296 | edit_modal_input_domain.get_untracked(), 297 | edit_modal_input_catchall.get_untracked(), 298 | edit_modal_input_public.get_untracked(), 299 | edit_modal_input_active.get_untracked(), 300 | edit_modal_input_owner.get_untracked(), 301 | ) 302 | .await 303 | { 304 | on_error(e.to_string()) 305 | } else { 306 | reload_controller.reload(); 307 | edit_modal_domain.set(None); 308 | } 309 | }); 310 | }; 311 | 312 | let on_row_change = move |ev: ChangeEvent| { 313 | spawn_local(async move { 314 | if let Err(e) = update_domain_public_and_active( 315 | ev.changed_row.domain.clone(), 316 | ev.changed_row.public, 317 | ev.changed_row.active, 318 | ) 319 | .await 320 | { 321 | error!( 322 | "Failed to update public or active status of {}: {}", 323 | ev.changed_row.domain, e 324 | ); 325 | } 326 | reload_controller.reload(); 327 | }); 328 | }; 329 | 330 | #[allow(unused_variables, non_snake_case)] 331 | let domain_row_renderer = move |class: Signal, 332 | row: Domain, 333 | index: usize, 334 | selected: Signal, 335 | on_select: EventHandler, 336 | on_change: EventHandler>| { 337 | let delete_domain = row.domain.clone(); 338 | let edit_domain = row.clone(); 339 | view! { 340 | 341 | {row.render_row(index, on_change)} 342 | 343 |
344 | 350 | 361 |
362 | 363 | 364 | } 365 | }; 366 | 367 | view! { 368 |
369 |
370 |

Domains

371 |
372 |
373 |
374 | 383 | 384 | 392 |
393 |
394 | {count} " results" 395 |
396 |
397 | 398 |
399 |
400 | 401 | 411 |
412 |
413 |
414 |
415 |
416 | 417 | 431 | 432 | 439 |
440 | 446 | 454 |
455 |
456 | 462 | 469 |
470 |
471 | 477 | 485 |
486 | 487 |
488 | 495 | 501 |
502 |
503 |
504 | 511 | 517 |
518 |
519 | } 520 | } 521 | -------------------------------------------------------------------------------- /src/app.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | aliases::{alias_count, count_sent_or_received, Aliases}, 3 | auth::{get_user, Login, LoginView, Logout}, 4 | domains::Domains, 5 | mailboxes::Mailboxes, 6 | users::{AccountSettings, Users}, 7 | utils::ColorModeToggle, 8 | }; 9 | use chrono::{Months, Utc}; 10 | use leptos::{html::Div, *}; 11 | use leptos_icons::Icon; 12 | use leptos_meta::{provide_meta_context, Body, Link, Stylesheet, Title}; 13 | use leptos_router::{ActionForm, Redirect, Route, Router, Routes, A}; 14 | use leptos_use::{ 15 | on_click_outside_with_options, use_color_mode_with_options, use_preferred_dark, ColorMode, OnClickOutsideOptions, 16 | UseColorModeOptions, 17 | }; 18 | 19 | #[derive(Copy, Clone, PartialEq, Eq)] 20 | pub enum Tab { 21 | Aliases, 22 | Mailboxes, 23 | Domains, 24 | Users, 25 | AccountSettings, 26 | } 27 | 28 | #[component] 29 | pub fn App() -> impl IntoView { 30 | provide_meta_context(); 31 | 32 | let login = create_server_action::(); 33 | let logout = create_server_action::(); 34 | let color_mode = use_color_mode_with_options(UseColorModeOptions::default().emit_auto(true)); 35 | let prefers_dark = use_preferred_dark(); 36 | let body_class = Signal::derive(move || { 37 | let dark = match (color_mode.mode)() { 38 | ColorMode::Light => "", 39 | ColorMode::Dark => "dark", 40 | ColorMode::Auto | _ => { 41 | if prefers_dark() { 42 | "dark" 43 | } else { 44 | "" 45 | } 46 | } 47 | }; 48 | format!("bg-white text-black dark:bg-black dark:text-zinc-100 {}", dark) 49 | }); 50 | 51 | view! { 52 | 53 | 54 | 55 | <Body class=body_class/> 56 | <Router> 57 | <main> 58 | <Routes> 59 | <Route 60 | path="/" 61 | view=move || { 62 | view! { 63 | <Title text="Login"/> 64 | <Redirect path="/login"/> 65 | } 66 | } 67 | /> 68 | 69 | <Route 70 | path="/login" 71 | view=move || { 72 | view! { 73 | <Title text="Login"/> 74 | <LoginView login logout color_mode=color_mode.mode set_color_mode=color_mode.set_mode/> 75 | } 76 | } 77 | /> 78 | 79 | <Route 80 | path="/aliases" 81 | view=move || { 82 | view! { 83 | <Title text="Aliases"/> 84 | <Tab 85 | login 86 | logout 87 | color_mode=color_mode.mode 88 | set_color_mode=color_mode.set_mode 89 | tab=Tab::Aliases 90 | /> 91 | } 92 | } 93 | /> 94 | 95 | <Route 96 | path="/mailboxes" 97 | view=move || { 98 | view! { 99 | <Title text="Mailboxes"/> 100 | <Tab 101 | login 102 | logout 103 | color_mode=color_mode.mode 104 | set_color_mode=color_mode.set_mode 105 | tab=Tab::Mailboxes 106 | /> 107 | } 108 | } 109 | /> 110 | 111 | <Route 112 | path="/domains" 113 | view=move || { 114 | view! { 115 | <Title text="Domains"/> 116 | <Tab 117 | login 118 | logout 119 | color_mode=color_mode.mode 120 | set_color_mode=color_mode.set_mode 121 | tab=Tab::Domains 122 | /> 123 | } 124 | } 125 | /> 126 | 127 | <Route 128 | path="/users" 129 | view=move || { 130 | view! { 131 | <Title text="Users"/> 132 | <Tab 133 | login 134 | logout 135 | color_mode=color_mode.mode 136 | set_color_mode=color_mode.set_mode 137 | tab=Tab::Users 138 | /> 139 | } 140 | } 141 | /> 142 | 143 | <Route 144 | path="/account" 145 | view=move || { 146 | view! { 147 | <Title text="Account Settings"/> 148 | <Tab 149 | login 150 | logout 151 | color_mode=color_mode.mode 152 | set_color_mode=color_mode.set_mode 153 | tab=Tab::AccountSettings 154 | /> 155 | } 156 | } 157 | /> 158 | 159 | </Routes> 160 | </main> 161 | </Router> 162 | } 163 | } 164 | 165 | #[component] 166 | pub fn Tab( 167 | login: Action<Login, Result<(), ServerFnError>>, 168 | logout: Action<Logout, Result<(), ServerFnError>>, 169 | color_mode: Signal<ColorMode>, 170 | set_color_mode: WriteSignal<ColorMode>, 171 | tab: Tab, 172 | ) -> impl IntoView { 173 | let user = create_resource( 174 | move || (login.version().get(), logout.version().get()), 175 | move |_| get_user(), 176 | ); 177 | 178 | let class_for = move |t| { 179 | let a_class_inactive = "inline-flex flex-1 sm:flex-none items-center justify-ceter whitespace-nowrap font-medium text-base hover:text-indigo-700 dark:hover:text-indigo-300 py-2.5 px-4 transition-all rounded-lg focus-visible:ring-4 hover:bg-indigo-200 dark:hover:bg-indigo-900 focus-visible:ring-blue-300 dark:focus-visible:ring-blue-900".to_string(); 180 | let a_class_active = 181 | format!("{a_class_inactive} bg-indigo-100 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-100"); 182 | if t == tab { 183 | a_class_active 184 | } else { 185 | a_class_inactive 186 | } 187 | }; 188 | 189 | let account_dropdown = create_node_ref::<Div>(); 190 | let (show_account_dropdown, set_show_account_dropdown) = create_signal(false); 191 | let toggle_show_account_dropdown = move || set_show_account_dropdown.update(|val| *val = !*val); 192 | let _ = on_click_outside_with_options( 193 | account_dropdown, 194 | move |_event| { 195 | set_show_account_dropdown(false); 196 | }, 197 | OnClickOutsideOptions::default().ignore(["#account-button"]), 198 | ); 199 | 200 | let active_alias_count = create_resource(|| (), |_| async move { alias_count(Some(true), None).await }); 201 | let inactive_alias_count = create_resource(|| (), |_| async move { alias_count(Some(false), None).await }); 202 | let total_recv_via_aliases = create_resource(|| (), |_| async move { count_sent_or_received(false).await }); 203 | let total_sent_via_aliases = create_resource(|| (), |_| async move { count_sent_or_received(true).await }); 204 | let new_since_last_month = create_resource( 205 | || (), 206 | |_| async move { alias_count(None, Some(Utc::now() - Months::new(1))).await }, 207 | ); 208 | let reload_stats = Callback::new(move |_: ()| { 209 | active_alias_count.refetch(); 210 | inactive_alias_count.refetch(); 211 | total_recv_via_aliases.refetch(); 212 | total_sent_via_aliases.refetch(); 213 | }); 214 | 215 | view! { 216 | <Transition fallback=move || { 217 | view! { <span class="text-gray-300 dark:text-gray-600">"Loading..."</span> } 218 | }> 219 | {move || { 220 | user.get() 221 | .map(|user| match user { 222 | Ok(Some(user)) => { 223 | let is_mailbox = user.mailbox_owner.is_some(); 224 | view! { 225 | <div class="flex flex-col sm:flex-row items-center py-6 px-4 md:px-12"> 226 | <div class="flex-1 flex flex-col sm:flex-row items-center w-full sm:w-auto"> 227 | <A href="/aliases" class="flex flex-row items-center mb-4 sm:mb-0 items-center"> 228 | <img class="w-16 h-16 me-2" src="/logo.svg"/> 229 | <h2 class="text-4xl leading-none font-bold inline-block">idmail</h2> 230 | </A> 231 | <div class="flex flex-row w-full sm:w-auto items-center gap-4 sm:ml-12 mb-4 sm:mb-0"> 232 | <A href="/aliases" class=class_for(Tab::Aliases)> 233 | "Aliases" 234 | </A> 235 | <Show when=move || !is_mailbox> 236 | <A href="/mailboxes" class=class_for(Tab::Mailboxes)> 237 | "Mailboxes" 238 | </A> 239 | <A href="/domains" class=class_for(Tab::Domains)> 240 | "Domains" 241 | </A> 242 | </Show> 243 | <Show when=move || user.admin> 244 | <A href="/users" class=class_for(Tab::Users)> 245 | "Users" 246 | </A> 247 | </Show> 248 | </div> 249 | </div> 250 | <div class="flex flex-row items-center w-full sm:w-auto relative"> 251 | <div class="flex-1 sm:flex-none"></div> 252 | 253 | <ColorModeToggle color_mode set_color_mode/> 254 | <button 255 | id="account-button" 256 | type="button" 257 | class="flex items-center text-sm pe-1 font-medium text-gray-900 dark:text-gray-200 rounded-lg hover:text-indigo-600 dark:hover:text-indigo-400 md:me-0" 258 | on:click=move |_ev| { 259 | toggle_show_account_dropdown(); 260 | } 261 | > 262 | 263 | {user.username.clone()} 264 | <svg 265 | class="w-2.5 h-2.5 ms-3" 266 | xmlns="http://www.w3.org/2000/svg" 267 | fill="none" 268 | viewBox="0 0 10 6" 269 | > 270 | <path 271 | stroke="currentColor" 272 | stroke-linecap="round" 273 | stroke-linejoin="round" 274 | stroke-width="2" 275 | d="m1 1 4 4 4-4" 276 | ></path> 277 | </svg> 278 | </button> 279 | 280 | <div 281 | node_ref=account_dropdown 282 | class="z-10 bg-white dark:bg-black divide-y-[1.5px] divide-gray-200 dark:divide-zinc-800 rounded-lg border-[1.5px] border-gray-200 dark:border-zinc-800 min-w-44 max-w-80 hidden absolute top-6 right-0" 283 | class=("!block", show_account_dropdown) 284 | > 285 | <div class="px-4 py-3 text-sm text-gray-900 dark:text-gray-200"> 286 | <div class="font-medium"> 287 | {if user.admin { 288 | "Admin" 289 | } else if is_mailbox { 290 | "Mailbox" 291 | } else { 292 | "User" 293 | }} 294 | 295 | </div> 296 | <div class="truncate">{user.username.clone()}</div> 297 | </div> 298 | <ul class="py-2 text-sm"> 299 | <li> 300 | <A 301 | href="/account" 302 | class="block px-4 py-2 w-full text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700" 303 | > 304 | "Settings" 305 | </A> 306 | </li> 307 | </ul> 308 | <div class="py-2"> 309 | <ActionForm action=logout> 310 | <button 311 | type="submit" 312 | class="block px-4 py-2 w-full text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700" 313 | > 314 | "Sign Out" 315 | </button> 316 | </ActionForm> 317 | </div> 318 | </div> 319 | </div> 320 | </div> 321 | <div class="overflow-hidden px-4 md:px-12"> 322 | <Show when=move || tab == Tab::Aliases> 323 | <div class="grid gap-4 lg:grid-cols-3"> 324 | <div class="rounded-xl border-[1.5px] border-gray-200 dark:border-zinc-800"> 325 | <div class="p-4 flex flex-row items-center justify-between space-y-0 pb-2"> 326 | <h3 class="tracking-tight text-sm font-medium">Aliases</h3> 327 | <Icon icon=icondata::TbMailForward class="w-5 h-5"/> 328 | </div> 329 | <div class="p-4 pt-0"> 330 | <div class="text-2xl font-bold"> 331 | <Transition fallback=move || { 332 | view! { <p class="animate-pulse">"..."</p> } 333 | }> 334 | {move || match active_alias_count.get() { 335 | Some(Ok(count)) => view! { {count} }.into_view(), 336 | _ => view! {}.into_view(), 337 | }} 338 | " active" 339 | 340 | </Transition> 341 | </div> 342 | <p class="text-xs text-gray-500 dark:text-gray-400"> 343 | <Transition fallback=move || { 344 | view! { <span class="animate-pulse">"..."</span> } 345 | }> 346 | {move || match inactive_alias_count.get() { 347 | Some(Ok(count)) => view! { {count} }.into_view(), 348 | _ => view! {}.into_view(), 349 | }} 350 | " inactive, " 351 | </Transition> 352 | <Transition fallback=move || { 353 | view! { <span class="animate-pulse">"..."</span> } 354 | }> 355 | "+" 356 | {move || match new_since_last_month.get() { 357 | Some(Ok(count)) => view! { {count} }.into_view(), 358 | _ => view! {}.into_view(), 359 | }} 360 | " new last month" 361 | </Transition> 362 | </p> 363 | </div> 364 | </div> 365 | <div class="rounded-xl border-[1.5px] border-gray-200 dark:border-zinc-800"> 366 | <div class="p-4 flex flex-row items-center justify-between space-y-0 pb-2"> 367 | <h3 class="tracking-tight text-sm font-medium"> 368 | Total received via aliases 369 | </h3> 370 | <Icon icon=icondata::BsArrowDown class="w-5 h-5"/> 371 | </div> 372 | <div class="p-4 pt-0"> 373 | <div class="text-2xl font-bold"> 374 | <Transition fallback=move || { 375 | view! { <span class="animate-pulse">"..."</span> } 376 | }> 377 | {move || match total_recv_via_aliases.get() { 378 | Some(Ok(count)) => view! { {count} }.into_view(), 379 | _ => view! {}.into_view(), 380 | }} 381 | 382 | </Transition> 383 | </div> 384 | </div> 385 | </div> 386 | <div class="rounded-xl border-[1.5px] border-gray-200 dark:border-zinc-800"> 387 | <div class="p-4 flex flex-row items-center justify-between space-y-0 pb-2"> 388 | <h3 class="tracking-tight text-sm font-medium"> 389 | Total sent via aliases 390 | </h3> 391 | <Icon icon=icondata::BsArrowUp class="w-5 h-5"/> 392 | </div> 393 | <div class="p-4 pt-0"> 394 | <div class="text-2xl font-bold"> 395 | <Transition fallback=move || { 396 | view! { <span class="animate-pulse">"..."</span> } 397 | }> 398 | {move || match total_sent_via_aliases.get() { 399 | Some(Ok(count)) => view! { {count} }.into_view(), 400 | _ => view! {}.into_view(), 401 | }} 402 | 403 | </Transition> 404 | </div> 405 | </div> 406 | </div> 407 | </div> 408 | </Show> 409 | 410 | {match tab { 411 | Tab::Aliases => view! { <Aliases user=user.clone() reload_stats/> }.into_view(), 412 | Tab::Mailboxes => { 413 | view! { <Mailboxes user=user.clone() reload_stats/> }.into_view() 414 | } 415 | Tab::Domains => view! { <Domains user=user.clone()/> }.into_view(), 416 | Tab::Users => view! { <Users/> }.into_view(), 417 | Tab::AccountSettings => { 418 | view! { <AccountSettings user=user.clone()/> }.into_view() 419 | } 420 | }} 421 | 422 | </div> 423 | } 424 | .into_view() 425 | } 426 | _ => view! { <Redirect path="/login"/> }.into_view(), 427 | }) 428 | }} 429 | 430 | </Transition> 431 | } 432 | } 433 | -------------------------------------------------------------------------------- /src/mailboxes.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | use std::ops::Range; 3 | 4 | use crate::aliases::validate_address; 5 | use crate::users::is_valid_pw; 6 | use crate::utils::{DeleteModal, EditModal, Select}; 7 | use crate::utils::{SliderRenderer, THeadCellRenderer, TailwindClassesPreset, TimediffRenderer}; 8 | 9 | use crate::auth::User; 10 | use chrono::{DateTime, Utc}; 11 | use leptos::leptos_dom::is_browser; 12 | use leptos::{ev::MouseEvent, logging::error, *}; 13 | use leptos_icons::Icon; 14 | use leptos_struct_table::*; 15 | use leptos_use::use_debounce_fn_with_arg; 16 | use serde::{Deserialize, Serialize}; 17 | #[cfg(feature = "ssr")] 18 | use sqlx::QueryBuilder; 19 | 20 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TableRow)] 21 | #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] 22 | #[table(sortable, classes_provider = TailwindClassesPreset, thead_cell_renderer = THeadCellRenderer)] 23 | pub struct Mailbox { 24 | pub address: String, 25 | #[table(skip)] 26 | pub password_hash: String, 27 | #[table(class = "w-1", renderer = "SliderRenderer")] 28 | pub active: bool, 29 | #[table(class = "w-1")] 30 | pub owner: String, 31 | #[table(class = "w-1", title = "Created", renderer = "TimediffRenderer")] 32 | pub created_at: DateTime<Utc>, 33 | } 34 | 35 | #[derive(Clone, Debug, Serialize, Deserialize)] 36 | pub struct MailboxQuery { 37 | #[serde(default)] 38 | sort: VecDeque<(usize, ColumnSort)>, 39 | range: Range<usize>, 40 | search: String, 41 | } 42 | 43 | #[server] 44 | pub async fn allowed_targets() -> Result<Vec<String>, ServerFnError> { 45 | let user = crate::auth::auth_any().await?; 46 | 47 | // Mailbox users can only target themselves 48 | if user.mailbox_owner.is_some() { 49 | return Ok(vec![user.username]); 50 | } 51 | 52 | let mut query = QueryBuilder::new("SELECT address FROM mailboxes"); 53 | query.push(" WHERE owner = "); 54 | query.push_bind(&user.username); 55 | 56 | let pool = crate::database::ssr::pool()?; 57 | Ok(query.build_query_scalar::<String>().fetch_all(&pool).await?) 58 | } 59 | 60 | #[server] 61 | pub async fn list_mailboxes(query: MailboxQuery) -> Result<Vec<Mailbox>, ServerFnError> { 62 | let user = crate::auth::auth_user().await?; 63 | 64 | let MailboxQuery { sort, range, search } = query; 65 | 66 | let mut query = QueryBuilder::new("SELECT * FROM mailboxes WHERE 1=1"); 67 | if !user.admin { 68 | query.push(" AND owner = "); 69 | query.push_bind(&user.username); 70 | } 71 | if !search.is_empty() { 72 | query.push(" AND ( address LIKE concat('%', "); 73 | query.push_bind(&search); 74 | query.push(", '%') OR owner LIKE concat('%', "); 75 | query.push_bind(&search); 76 | query.push(", '%') )"); 77 | } 78 | 79 | if let Some(order) = Mailbox::sorting_to_sql(&sort) { 80 | query.push(" "); 81 | query.push(order); 82 | } 83 | 84 | query.push(" LIMIT "); 85 | query.push_bind(range.len() as i64); 86 | query.push(" OFFSET "); 87 | query.push_bind(range.start as i64); 88 | 89 | let pool = crate::database::ssr::pool()?; 90 | Ok(query.build_query_as::<Mailbox>().fetch_all(&pool).await?) 91 | } 92 | 93 | #[server] 94 | pub async fn mailbox_count() -> Result<usize, ServerFnError> { 95 | let user = crate::auth::auth_user().await?; 96 | 97 | let mut query = QueryBuilder::new("SELECT COUNT(*) FROM mailboxes"); 98 | if !user.admin { 99 | query.push(" WHERE owner = "); 100 | query.push_bind(&user.username); 101 | } 102 | 103 | let pool = crate::database::ssr::pool()?; 104 | let count = query.build_query_scalar::<i64>().fetch_one(&pool).await?; 105 | 106 | Ok(count as usize) 107 | } 108 | 109 | #[server] 110 | pub async fn delete_mailbox(address: String) -> Result<(), ServerFnError> { 111 | let user = crate::auth::auth_user().await?; 112 | 113 | let mut query = QueryBuilder::new("DELETE FROM mailboxes WHERE address = "); 114 | query.push_bind(address); 115 | 116 | // Non-admins can only delete their own mailboxes 117 | if !user.admin { 118 | query.push(" AND owner = "); 119 | query.push_bind(&user.username); 120 | } 121 | 122 | let pool = crate::database::ssr::pool()?; 123 | query.build().execute(&pool).await.map(|_| ())?; 124 | Ok(()) 125 | } 126 | 127 | #[server] 128 | pub async fn create_or_update_mailbox( 129 | old_address: Option<String>, 130 | localpart: String, 131 | domain: String, 132 | password: String, 133 | active: bool, 134 | owner: String, 135 | ) -> Result<(), ServerFnError> { 136 | use crate::domains::allowed_domains; 137 | use crate::users::mk_password_hash; 138 | 139 | let user = crate::auth::auth_user().await?; 140 | let pool = crate::database::ssr::pool()?; 141 | 142 | // Only admins can assign other owners 143 | let owner = if user.admin { owner.trim() } else { &user.username }; 144 | // Empty owner -> self owned 145 | let owner = if owner.is_empty() { &user.username } else { owner }; 146 | 147 | // Check if address is valid 148 | let allowed_domains = allowed_domains().await?; 149 | let Some((_, domain_owner)) = allowed_domains.iter().find(|x| x.0 == domain) else { 150 | return Err(ServerFnError::new("domain must be set to a valid domain")); 151 | }; 152 | 153 | let address = validate_address(&localpart, &domain, user.admin || *domain_owner == user.username) 154 | .map_err(ServerFnError::new)?; 155 | 156 | let mut query = if let Some(old_address) = old_address { 157 | let mut query = QueryBuilder::new("UPDATE mailboxes SET address = "); 158 | query.push_bind(&address); 159 | query.push(", domain = "); 160 | query.push_bind(domain); 161 | if !password.is_empty() { 162 | let password_hash = mk_password_hash(&password)?; 163 | query.push(", password_hash = "); 164 | query.push_bind(password_hash); 165 | } 166 | query.push(", active = "); 167 | query.push_bind(active); 168 | query.push(", owner = "); 169 | query.push_bind(owner); 170 | query.push(" WHERE address = "); 171 | query.push_bind(old_address); 172 | if !user.admin { 173 | query.push(" AND owner = "); 174 | query.push_bind(&user.username); 175 | } 176 | // make sure that no alias exists with that address 177 | query.push(" AND NOT EXISTS (SELECT * FROM aliases WHERE address = "); 178 | query.push_bind(&address); 179 | query.push(")"); 180 | 181 | query 182 | } else { 183 | let password_hash = mk_password_hash(&password)?; 184 | let mut query = QueryBuilder::new("INSERT INTO mailboxes (address, domain, password_hash, active, owner)"); 185 | query.push("SELECT "); 186 | query.push_bind(&address); 187 | query.push(", "); 188 | query.push_bind(domain); 189 | query.push(", "); 190 | query.push_bind(password_hash); 191 | query.push(", "); 192 | query.push_bind(active); 193 | query.push(", "); 194 | query.push_bind(owner); 195 | // make sure that no alias exists with that address 196 | query.push(" WHERE NOT EXISTS (SELECT * FROM aliases WHERE address = "); 197 | query.push_bind(&address); 198 | query.push(")"); 199 | 200 | query 201 | }; 202 | 203 | if query.build().execute(&pool).await?.rows_affected() == 0 { 204 | return Err(ServerFnError::new("This address is already in use by an alias!")); 205 | } 206 | 207 | Ok(()) 208 | } 209 | 210 | #[server] 211 | pub async fn update_mailbox_active(address: String, active: bool) -> Result<(), ServerFnError> { 212 | let user = crate::auth::auth_user().await?; 213 | let mut query = QueryBuilder::new("UPDATE mailboxes SET active = "); 214 | query.push_bind(active); 215 | query.push(" WHERE address = "); 216 | query.push_bind(address); 217 | 218 | // Non-admins can only change their own domains 219 | if !user.admin { 220 | query.push(" AND owner = "); 221 | query.push_bind(&user.username); 222 | } 223 | 224 | let pool = crate::database::ssr::pool()?; 225 | query.build().execute(&pool).await.map(|_| ())?; 226 | Ok(()) 227 | } 228 | 229 | #[derive(Default)] 230 | pub struct MailboxTableDataProvider { 231 | sort: VecDeque<(usize, ColumnSort)>, 232 | pub search: RwSignal<String>, 233 | } 234 | 235 | impl TableDataProvider<Mailbox> for MailboxTableDataProvider { 236 | async fn get_rows(&self, range: Range<usize>) -> Result<(Vec<Mailbox>, Range<usize>), String> { 237 | list_mailboxes(MailboxQuery { 238 | search: self.search.get_untracked().trim().to_string(), 239 | sort: self.sort.clone(), 240 | range: range.clone(), 241 | }) 242 | .await 243 | .map(|rows| { 244 | let len = rows.len(); 245 | (rows, range.start..range.start + len) 246 | }) 247 | .map_err(|e| format!("{e:?}")) 248 | } 249 | 250 | async fn row_count(&self) -> Option<usize> { 251 | mailbox_count().await.ok() 252 | } 253 | 254 | fn set_sorting(&mut self, sorting: &VecDeque<(usize, ColumnSort)>) { 255 | self.sort = sorting.clone(); 256 | } 257 | 258 | fn track(&self) { 259 | self.search.track(); 260 | } 261 | } 262 | 263 | #[component] 264 | pub fn Mailboxes(user: User, reload_stats: Callback<()>) -> impl IntoView { 265 | let mut rows = MailboxTableDataProvider::default(); 266 | let default_sorting = VecDeque::from([(3, ColumnSort::Descending)]); 267 | rows.set_sorting(&default_sorting); 268 | let sorting = create_rw_signal(default_sorting); 269 | 270 | let reload = create_trigger(); 271 | let reload_controller = ReloadController::default(); 272 | create_effect(move |_| { 273 | reload.track(); 274 | reload_controller.reload(); 275 | reload_stats(()); 276 | }); 277 | 278 | let on_input = use_debounce_fn_with_arg(move |value| rows.search.set(value), 300.0); 279 | let (count, set_count) = create_signal(0); 280 | 281 | let (allowed_domains, set_allowed_domains) = create_signal(vec![]); 282 | let refresh_domains = move || { 283 | spawn_local(async move { 284 | use crate::domains::allowed_domains; 285 | match allowed_domains().await { 286 | Err(e) => error!("Failed to load allowed domains: {}", e), 287 | Ok(domains) => set_allowed_domains(domains.into_iter().map(|x| x.0).collect()), 288 | } 289 | }); 290 | }; 291 | 292 | if is_browser() { 293 | refresh_domains(); 294 | } 295 | 296 | let delete_modal_mailbox = create_rw_signal(None); 297 | let edit_modal_mailbox = create_rw_signal(None); 298 | 299 | let (edit_modal_input_localpart, set_edit_modal_input_localpart) = create_signal("".to_string()); 300 | let (edit_modal_input_domain, set_edit_modal_input_domain) = create_signal("".to_string()); 301 | let (edit_modal_input_password, set_edit_modal_input_password) = create_signal("".to_string()); 302 | let (edit_modal_input_password_repeat, set_edit_modal_input_password_repeat) = create_signal("".to_string()); 303 | let (edit_modal_input_active, set_edit_modal_input_active) = create_signal(true); 304 | let (edit_modal_input_owner, set_edit_modal_input_owner) = create_signal("".to_string()); 305 | let edit_modal_open_with = Callback::new(move |edit_mailbox: Option<Mailbox>| { 306 | refresh_domains(); 307 | edit_modal_mailbox.set(Some(edit_mailbox.clone())); 308 | set_edit_modal_input_password("".to_string()); 309 | set_edit_modal_input_password_repeat("".to_string()); 310 | 311 | let allowed_domains = allowed_domains.get(); 312 | if let Some(edit_mailbox) = edit_mailbox { 313 | let (localpart, domain) = match edit_mailbox.address.split_once('@') { 314 | Some((localpart, domain)) => (localpart.to_string(), domain.to_string()), 315 | None => (edit_mailbox.address.clone(), "".to_string()), 316 | }; 317 | set_edit_modal_input_localpart(localpart.to_string()); 318 | if !allowed_domains.contains(&domain) { 319 | set_edit_modal_input_domain(allowed_domains.first().cloned().unwrap_or("".to_string())); 320 | } else { 321 | set_edit_modal_input_domain(domain); 322 | } 323 | set_edit_modal_input_active(edit_mailbox.active); 324 | set_edit_modal_input_owner(edit_mailbox.owner.clone()); 325 | } else { 326 | // Only set the input domain if the current one is not in the list 327 | // of allowed domains. This allows users to keep the old value 328 | // between mailbox creations, making it easier to create multiple 329 | // mailboxes on the same domain. 330 | set_edit_modal_input_localpart("".to_string()); 331 | if !allowed_domains.contains(&edit_modal_input_domain()) { 332 | set_edit_modal_input_domain(allowed_domains.first().cloned().unwrap_or("".to_string())); 333 | } 334 | set_edit_modal_input_active(true); 335 | set_edit_modal_input_owner("".to_string()); 336 | } 337 | }); 338 | 339 | let on_edit = move |(data, on_error): (Option<Mailbox>, Callback<String>)| { 340 | spawn_local(async move { 341 | if let Err(e) = create_or_update_mailbox( 342 | data.map(|x| x.address), 343 | edit_modal_input_localpart.get_untracked(), 344 | edit_modal_input_domain.get_untracked(), 345 | edit_modal_input_password.get_untracked(), 346 | edit_modal_input_active.get_untracked(), 347 | edit_modal_input_owner.get_untracked(), 348 | ) 349 | .await 350 | { 351 | on_error(e.to_string()) 352 | } else { 353 | reload.notify(); 354 | edit_modal_mailbox.set(None); 355 | } 356 | }); 357 | }; 358 | 359 | let on_row_change = move |ev: ChangeEvent<Mailbox>| { 360 | spawn_local(async move { 361 | if let Err(e) = update_mailbox_active(ev.changed_row.address.clone(), ev.changed_row.active).await { 362 | error!("Failed to update active status of {}: {}", ev.changed_row.address, e); 363 | } 364 | reload.notify(); 365 | }); 366 | }; 367 | 368 | #[allow(unused_variables, non_snake_case)] 369 | let mailbox_row_renderer = move |class: Signal<String>, 370 | row: Mailbox, 371 | index: usize, 372 | selected: Signal<bool>, 373 | on_select: EventHandler<MouseEvent>, 374 | on_change: EventHandler<ChangeEvent<Mailbox>>| { 375 | let delete_address = row.address.clone(); 376 | let edit_mailbox = row.clone(); 377 | view! { 378 | <tr class=class on:click=move |mouse_event| on_select.run(mouse_event)> 379 | {row.render_row(index, on_change)} 380 | <td class="w-1 px-4 py-2 whitespace-nowrap text-ellipsis"> 381 | <div class="inline-flex items-center rounded-md"> 382 | <button 383 | class="text-gray-800 dark:text-zinc-100 hover:text-white dark:hover:text-black bg-white dark:bg-black hover:bg-blue-600 dark:hover:bg-blue-500 transition-all border-[1.5px] border-gray-200 dark:border-zinc-800 rounded-l-lg font-medium px-4 py-2 inline-flex space-x-1 items-center" 384 | on:click=move |_| edit_modal_open_with(Some(edit_mailbox.clone())) 385 | > 386 | <Icon icon=icondata::FiEdit class="w-5 h-5"/> 387 | </button> 388 | <button 389 | class="text-gray-800 dark:text-zinc-100 hover:text-white dark:hover:text-black bg-white dark:bg-black hover:bg-red-600 dark:hover:bg-red-500 transition-all border-l-0 border-[1.5px] border-gray-200 dark:border-zinc-800 rounded-r-lg font-medium px-4 py-2 inline-flex space-x-1 items-center" 390 | on:click=move |_| { 391 | delete_modal_mailbox.set(Some(delete_address.clone())); 392 | } 393 | > 394 | 395 | <Icon icon=icondata::FiTrash2 class="w-5 h-5"/> 396 | </button> 397 | </div> 398 | </td> 399 | </tr> 400 | } 401 | }; 402 | 403 | let has_password_mismatch = move || edit_modal_input_password() != edit_modal_input_password_repeat(); 404 | let has_invalid_password = create_memo(move |_| { 405 | // Either we edit an existing mailbox (in which case an empty password means no change) 406 | // or the password is of correct length. 407 | let is_new = matches!(edit_modal_mailbox.get(), Some(None)); 408 | let is_valid_pw = is_valid_pw(&edit_modal_input_password()); 409 | let valid = is_valid_pw || (!is_new && edit_modal_input_password().is_empty()); 410 | !valid 411 | }); 412 | let has_invalid_address = create_memo(move |_| { 413 | validate_address( 414 | &edit_modal_input_localpart(), 415 | &edit_modal_input_domain(), 416 | true, /* error on create to save resources */ 417 | ) 418 | .is_err() 419 | }); 420 | let errors = create_memo(move |_| { 421 | let mut errors = Vec::new(); 422 | if let Err(e) = validate_address( 423 | &edit_modal_input_localpart(), 424 | &edit_modal_input_domain(), 425 | true, /* error on create to save resources */ 426 | ) { 427 | errors.push(format!("invalid address: {}", e)); 428 | } 429 | if has_password_mismatch() { 430 | errors.push("Passwords don't match".to_string()); 431 | } 432 | if has_invalid_password() { 433 | errors.push("Password must be between 12 and 512 characters".to_string()); 434 | } 435 | errors 436 | }); 437 | 438 | view! { 439 | <div class="h-full flex-1 flex-col mt-12"> 440 | <div class="flex items-center justify-between space-y-2 mb-4"> 441 | <h2 class="text-4xl font-bold">Mailboxes</h2> 442 | </div> 443 | <div class="space-y-4"> 444 | <div class="flex flex-wrap items-center justify-between"> 445 | <input 446 | class="flex flex-none rounded-lg border-[1.5px] border-gray-200 dark:border-zinc-800 bg-transparent dark:bg-transparent text-base p-2.5 me-2 mb-2 w-full md:w-[360px] lg:w-[520px] transition-all placeholder:text-gray-500 dark:placeholder:text-zinc-500 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" 447 | type="search" 448 | placeholder="Search" 449 | value=rows.search 450 | on:input=move |e| { 451 | on_input(event_target_value(&e)); 452 | } 453 | /> 454 | 455 | <button 456 | type="button" 457 | class="inline-flex flex-none items-center justify-center whitespace-nowrap font-medium text-base text-white dark:text-zinc-100 py-2.5 px-4 me-2 mb-2 transition-all rounded-lg focus:ring-4 bg-blue-600 dark:bg-blue-700 hover:bg-blue-500 dark:hover:bg-blue-600 focus:ring-blue-300 dark:focus:ring-blue-900" 458 | on:click=move |_| edit_modal_open_with(None) 459 | > 460 | <Icon icon=icondata::FiPlus class="w-6 h-6 me-2"/> 461 | New 462 | </button> 463 | <div class="flex flex-1"></div> 464 | <div class="inline-flex flex-none items-center justify-center whitespace-nowrap font-medium text-base text-right px-4"> 465 | {count} " results" 466 | </div> 467 | </div> 468 | 469 | <div class="rounded-lg border-[1.5px] border-gray-200 dark:border-zinc-800 text-base flex flex-col overflow-hidden"> 470 | <div class="overflow-auto grow min-h-0"> 471 | <table class="table-auto text-left w-full"> 472 | <TableContent 473 | rows 474 | sorting=sorting 475 | sorting_mode=SortingMode::SingleColumn 476 | row_renderer=mailbox_row_renderer 477 | reload_controller=reload_controller 478 | loading_row_display_limit=0 479 | on_row_count=set_count 480 | on_change=on_row_change 481 | /> 482 | </table> 483 | </div> 484 | </div> 485 | </div> 486 | </div> 487 | 488 | <DeleteModal 489 | data=delete_modal_mailbox 490 | text="Are you sure you want to delete this mailbox? This action cannot be undone.".into_view() 491 | on_confirm=move |data| { 492 | spawn_local(async move { 493 | if let Err(e) = delete_mailbox(data).await { 494 | error!("Failed to delete mailbox: {}", e); 495 | } else { 496 | reload.notify(); 497 | } 498 | delete_modal_mailbox.set(None); 499 | }); 500 | } 501 | /> 502 | 503 | <EditModal 504 | data=edit_modal_mailbox 505 | what="Mailbox".to_string() 506 | get_title=move |x| { &x.address } 507 | on_confirm=on_edit 508 | errors 509 | > 510 | <div class="flex flex-col sm:flex-row"> 511 | <div class="flex flex-1 flex-col gap-2"> 512 | <label 513 | class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 514 | for="mailbox" 515 | > 516 | Mailbox 517 | </label> 518 | <div class="flex flex-row"> 519 | <input 520 | class="flex sm:min-w-32 flex-1 rounded-lg border-[1.5px] border-gray-200 dark:border-zinc-800 bg-transparent dark:bg-transparent text-sm p-2.5 transition-all placeholder:text-gray-500 dark:placeholder:text-zinc-500 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" 521 | class=("!ring-4", has_invalid_address) 522 | class=("!ring-red-500", has_invalid_address) 523 | type="email" 524 | placeholder="mailbox" 525 | on:input=move |ev| set_edit_modal_input_localpart(event_target_value(&ev)) 526 | prop:value=edit_modal_input_localpart 527 | /> 528 | <span class="inline-flex flex-none text-base items-center mx-2">@</span> 529 | </div> 530 | </div> 531 | <div class="flex flex-col gap-2"> 532 | <label 533 | class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mt-3 sm:mt-0" 534 | for="domain" 535 | > 536 | Domain 537 | </label> 538 | <Select 539 | class="w-full h-full rounded-lg border-[1.5px] border-gray-200 dark:border-zinc-800 bg-transparent dark:bg-transparent text-sm p-2.5 transition-all focus:ring-4 focus:ring-blue-300 dark:focus:ring-blue-900" 540 | choices=allowed_domains 541 | value=edit_modal_input_domain 542 | set_value=set_edit_modal_input_domain 543 | /> 544 | </div> 545 | </div> 546 | <div class="flex flex-col gap-2"> 547 | <label 548 | class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 549 | for="password" 550 | > 551 | {move || { 552 | if matches!(edit_modal_mailbox.get(), Some(None)) { 553 | "Password" 554 | } else { 555 | "Password (leave empty to keep current)" 556 | } 557 | }} 558 | 559 | </label> 560 | <input 561 | class="flex flex-none w-full rounded-lg border-[1.5px] border-gray-200 dark:border-zinc-800 bg-transparent dark:bg-transparent text-sm p-2.5 transition-all placeholder:text-gray-500 dark:placeholder:text-zinc-500 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" 562 | class=("!ring-4", has_invalid_password) 563 | class=("!ring-red-500", has_invalid_password) 564 | type="password" 565 | required="required" 566 | maxlength="1024" 567 | on:input=move |ev| set_edit_modal_input_password(event_target_value(&ev)) 568 | prop:value=edit_modal_input_password 569 | /> 570 | </div> 571 | <div class="flex flex-col gap-2"> 572 | <label 573 | class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 574 | for="password_2" 575 | > 576 | Repeat Password 577 | </label> 578 | <input 579 | class="flex flex-none w-full rounded-lg border-[1.5px] border-gray-200 dark:border-zinc-800 bg-transparent dark:bg-transparent text-sm p-2.5 transition-all placeholder:text-gray-500 dark:placeholder:text-zinc-500 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" 580 | class=("!ring-4", has_password_mismatch) 581 | class=("!ring-red-500", has_password_mismatch) 582 | type="password" 583 | required="required" 584 | maxlength="1024" 585 | on:input=move |ev| set_edit_modal_input_password_repeat(event_target_value(&ev)) 586 | prop:value=edit_modal_input_password_repeat 587 | /> 588 | </div> 589 | <div class="flex flex-col gap-2"> 590 | <label 591 | class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 592 | for="owner" 593 | > 594 | Owner 595 | </label> 596 | <input 597 | class="flex flex-none w-full rounded-lg border-[1.5px] border-gray-200 dark:border-zinc-800 bg-transparent dark:bg-transparent text-sm p-2.5 transition-all placeholder:text-gray-500 dark:placeholder:text-zinc-500 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" 598 | type="text" 599 | placeholder=user.username.clone() 600 | on:input=move |ev| set_edit_modal_input_owner(event_target_value(&ev)) 601 | prop:value=edit_modal_input_owner 602 | disabled=!user.admin 603 | /> 604 | </div> 605 | <div class="flex flex-row gap-2 mt-2 items-center"> 606 | <input 607 | id="mailboxes_active" 608 | class="w-4 h-4 bg-transparent dark:bg-transparent text-blue-600 border-[1.5px] border-gray-200 dark:border-zinc-800 rounded checked:bg-blue-600 dark:checked:bg-blue-600 dark:bg-blue-600 focus:ring-ring focus:ring-4 transition-all" 609 | type="checkbox" 610 | on:change=move |ev| set_edit_modal_input_active(event_target_checked(&ev)) 611 | prop:checked=edit_modal_input_active 612 | /> 613 | <label 614 | class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 615 | for="mailboxes_active" 616 | > 617 | Active 618 | </label> 619 | </div> 620 | </EditModal> 621 | } 622 | } 623 | --------------------------------------------------------------------------------