aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/atuin-client/src/api_client.rs36
-rw-r--r--crates/atuin-common/Cargo.toml2
-rw-r--r--crates/atuin-common/src/api.rs16
-rw-r--r--crates/atuin-common/src/utils.rs33
-rw-r--r--crates/atuin-server-database/src/lib.rs5
-rw-r--r--crates/atuin-server-database/src/models.rs1
-rw-r--r--crates/atuin-server-postgres/migrations/20240621110731_user-verified.sql8
-rw-r--r--crates/atuin-server-postgres/src/lib.rs99
-rw-r--r--crates/atuin-server-postgres/src/wrappers.rs1
-rw-r--r--crates/atuin-server/Cargo.toml1
-rw-r--r--crates/atuin-server/src/handlers/user.rs110
-rw-r--r--crates/atuin-server/src/router.rs5
-rw-r--r--crates/atuin-server/src/settings.rs27
-rw-r--r--crates/atuin/src/command/client/account.rs5
-rw-r--r--crates/atuin/src/command/client/account/verify.rs47
-rw-r--r--crates/atuin/tests/common/mod.rs1
16 files changed, 381 insertions, 16 deletions
diff --git a/crates/atuin-client/src/api_client.rs b/crates/atuin-client/src/api_client.rs
index 60f2d300..53f24424 100644
--- a/crates/atuin-client/src/api_client.rs
+++ b/crates/atuin-client/src/api_client.rs
@@ -11,8 +11,9 @@ use reqwest::{
use atuin_common::{
api::{
AddHistoryRequest, ChangePasswordRequest, CountResponse, DeleteHistoryRequest,
- ErrorResponse, LoginRequest, LoginResponse, MeResponse, RegisterResponse, StatusResponse,
- SyncHistoryResponse,
+ ErrorResponse, LoginRequest, LoginResponse, MeResponse, RegisterResponse,
+ SendVerificationResponse, StatusResponse, SyncHistoryResponse, VerificationTokenRequest,
+ VerificationTokenResponse,
},
record::RecordStatus,
};
@@ -403,4 +404,35 @@ impl<'a> Client<'a> {
bail!("Unknown error");
}
}
+
+ // Either request a verification email if token is null, or validate a token
+ pub async fn verify(&self, token: Option<String>) -> Result<(bool, bool)> {
+ // could dedupe this a bit, but it's simple at the moment
+ let (email_sent, verified) = if let Some(token) = token {
+ let url = format!("{}/api/v0/account/verify", self.sync_addr);
+ let url = Url::parse(url.as_str())?;
+
+ let resp = self
+ .client
+ .post(url)
+ .json(&VerificationTokenRequest { token })
+ .send()
+ .await?;
+ let resp = handle_resp_error(resp).await?;
+ let resp = resp.json::<VerificationTokenResponse>().await?;
+
+ (false, resp.verified)
+ } else {
+ let url = format!("{}/api/v0/account/send-verification", self.sync_addr);
+ let url = Url::parse(url.as_str())?;
+
+ let resp = self.client.post(url).send().await?;
+ let resp = handle_resp_error(resp).await?;
+ let resp = resp.json::<SendVerificationResponse>().await?;
+
+ (resp.email_sent, resp.verified)
+ };
+
+ Ok((email_sent, verified))
+ }
}
diff --git a/crates/atuin-common/Cargo.toml b/crates/atuin-common/Cargo.toml
index 5fdcbfa7..f89c1d06 100644
--- a/crates/atuin-common/Cargo.toml
+++ b/crates/atuin-common/Cargo.toml
@@ -24,6 +24,8 @@ semver = { workspace = true }
thiserror = { workspace = true }
directories = { workspace = true }
sysinfo = "0.30.7"
+base64 = { workspace = true }
+getrandom = "0.2"
lazy_static = "1.4.0"
diff --git a/crates/atuin-common/src/api.rs b/crates/atuin-common/src/api.rs
index 99b57cec..4e897811 100644
--- a/crates/atuin-common/src/api.rs
+++ b/crates/atuin-common/src/api.rs
@@ -34,6 +34,22 @@ pub struct RegisterResponse {
pub struct DeleteUserResponse {}
#[derive(Debug, Serialize, Deserialize)]
+pub struct SendVerificationResponse {
+ pub email_sent: bool,
+ pub verified: bool,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct VerificationTokenRequest {
+ pub token: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct VerificationTokenResponse {
+ pub verified: bool,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
pub struct ChangePasswordRequest {
pub current_password: String,
pub new_password: String,
diff --git a/crates/atuin-common/src/utils.rs b/crates/atuin-common/src/utils.rs
index 65f5efc4..869866b0 100644
--- a/crates/atuin-common/src/utils.rs
+++ b/crates/atuin-common/src/utils.rs
@@ -4,17 +4,30 @@ use std::path::PathBuf;
use eyre::{eyre, Result};
-use rand::RngCore;
+use base64::prelude::{Engine, BASE64_URL_SAFE_NO_PAD};
+use getrandom::getrandom;
use uuid::Uuid;
-pub fn random_bytes<const N: usize>() -> [u8; N] {
+/// Generate N random bytes, using a cryptographically secure source
+pub fn crypto_random_bytes<const N: usize>() -> [u8; N] {
+ // rand say they are in principle safe for crypto purposes, but that it is perhaps a better
+ // idea to use getrandom for things such as passwords.
let mut ret = [0u8; N];
- rand::thread_rng().fill_bytes(&mut ret);
+ getrandom(&mut ret).expect("Failed to generate random bytes!");
ret
}
+/// Generate N random bytes using a cryptographically secure source, return encoded as a string
+pub fn crypto_random_string<const N: usize>() -> String {
+ let bytes = crypto_random_bytes::<N>();
+
+ // We only use this to create a random string, and won't be reversing it to find the original
+ // data - no padding is OK there. It may be in URLs.
+ BASE64_URL_SAFE_NO_PAD.encode(bytes)
+}
+
pub fn uuid_v7() -> Uuid {
Uuid::now_v7()
}
@@ -178,6 +191,7 @@ impl<T: AsRef<str>> Escapable for T {}
#[cfg(test)]
mod tests {
+ use pretty_assertions::assert_ne;
use time::Month;
use super::*;
@@ -292,4 +306,17 @@ mod tests {
Cow::Owned(_)
));
}
+
+ #[test]
+ fn dumb_random_test() {
+ // Obviously not a test of randomness, but make sure we haven't made some
+ // catastrophic error
+
+ assert_ne!(crypto_random_string::<1>(), crypto_random_string::<1>());
+ assert_ne!(crypto_random_string::<2>(), crypto_random_string::<2>());
+ assert_ne!(crypto_random_string::<4>(), crypto_random_string::<4>());
+ assert_ne!(crypto_random_string::<8>(), crypto_random_string::<8>());
+ assert_ne!(crypto_random_string::<16>(), crypto_random_string::<16>());
+ assert_ne!(crypto_random_string::<32>(), crypto_random_string::<32>());
+ }
}
diff --git a/crates/atuin-server-database/src/lib.rs b/crates/atuin-server-database/src/lib.rs
index d2c16b3d..f6933b94 100644
--- a/crates/atuin-server-database/src/lib.rs
+++ b/crates/atuin-server-database/src/lib.rs
@@ -53,6 +53,11 @@ pub trait Database: Sized + Clone + Send + Sync + 'static {
async fn get_user(&self, username: &str) -> DbResult<User>;
async fn get_user_session(&self, u: &User) -> DbResult<Session>;
async fn add_user(&self, user: &NewUser) -> DbResult<i64>;
+
+ async fn user_verified(&self, id: i64) -> DbResult<bool>;
+ async fn verify_user(&self, id: i64) -> DbResult<()>;
+ async fn user_verification_token(&self, id: i64) -> DbResult<String>;
+
async fn update_user_password(&self, u: &User) -> DbResult<()>;
async fn total_history(&self) -> DbResult<i64>;
diff --git a/crates/atuin-server-database/src/models.rs b/crates/atuin-server-database/src/models.rs
index b71a9bc9..894ac7f6 100644
--- a/crates/atuin-server-database/src/models.rs
+++ b/crates/atuin-server-database/src/models.rs
@@ -32,6 +32,7 @@ pub struct User {
pub username: String,
pub email: String,
pub password: String,
+ pub verified: Option<OffsetDateTime>,
}
pub struct Session {
diff --git a/crates/atuin-server-postgres/migrations/20240621110731_user-verified.sql b/crates/atuin-server-postgres/migrations/20240621110731_user-verified.sql
new file mode 100644
index 00000000..6eba02ec
--- /dev/null
+++ b/crates/atuin-server-postgres/migrations/20240621110731_user-verified.sql
@@ -0,0 +1,8 @@
+alter table users add verified_at timestamp with time zone default null;
+
+create table user_verification_token(
+ id bigserial primary key,
+ user_id bigint unique references users(id),
+ token text,
+ valid_until timestamp with time zone
+);
diff --git a/crates/atuin-server-postgres/src/lib.rs b/crates/atuin-server-postgres/src/lib.rs
index 8a010195..7aa87424 100644
--- a/crates/atuin-server-postgres/src/lib.rs
+++ b/crates/atuin-server-postgres/src/lib.rs
@@ -3,6 +3,7 @@ use std::ops::Range;
use async_trait::async_trait;
use atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
+use atuin_common::utils::crypto_random_string;
use atuin_server_database::models::{History, NewHistory, NewSession, NewUser, Session, User};
use atuin_server_database::{Database, DbError, DbResult};
use futures_util::TryStreamExt;
@@ -11,7 +12,7 @@ use sqlx::postgres::PgPoolOptions;
use sqlx::Row;
use time::{OffsetDateTime, PrimitiveDateTime, UtcOffset};
-use tracing::instrument;
+use tracing::{instrument, trace};
use uuid::Uuid;
use wrappers::{DbHistory, DbRecord, DbSession, DbUser};
@@ -100,18 +101,100 @@ impl Database for Postgres {
#[instrument(skip_all)]
async fn get_user(&self, username: &str) -> DbResult<User> {
- sqlx::query_as("select id, username, email, password from users where username = $1")
- .bind(username)
- .fetch_one(&self.pool)
- .await
- .map_err(fix_error)
- .map(|DbUser(user)| user)
+ sqlx::query_as(
+ "select id, username, email, password, verified_at from users where username = $1",
+ )
+ .bind(username)
+ .fetch_one(&self.pool)
+ .await
+ .map_err(fix_error)
+ .map(|DbUser(user)| user)
+ }
+
+ #[instrument(skip_all)]
+ async fn user_verified(&self, id: i64) -> DbResult<bool> {
+ let res: (bool,) =
+ sqlx::query_as("select verified_at is not null from users where id = $1")
+ .bind(id)
+ .fetch_one(&self.pool)
+ .await
+ .map_err(fix_error)?;
+
+ Ok(res.0)
+ }
+
+ #[instrument(skip_all)]
+ async fn verify_user(&self, id: i64) -> DbResult<()> {
+ sqlx::query(
+ "update users set verified_at = (current_timestamp at time zone 'utc') where id=$1",
+ )
+ .bind(id)
+ .execute(&self.pool)
+ .await
+ .map_err(fix_error)?;
+
+ Ok(())
+ }
+
+ /// Return a valid verification token for the user
+ /// If the user does not have any token, create one, insert it, and return
+ /// If the user has a token, but it's invalid, delete it, create a new one, return
+ /// If the user already has a valid token, return it
+ #[instrument(skip_all)]
+ async fn user_verification_token(&self, id: i64) -> DbResult<String> {
+ const TOKEN_VALID_MINUTES: i64 = 15;
+
+ // First we check if there is a verification token
+ let token: Option<(String, sqlx::types::time::OffsetDateTime)> = sqlx::query_as(
+ "select token, valid_until from user_verification_token where user_id = $1",
+ )
+ .bind(id)
+ .fetch_optional(&self.pool)
+ .await
+ .map_err(fix_error)?;
+
+ let token = if let Some((token, valid_until)) = token {
+ trace!("Token for user {id} valid until {valid_until}");
+
+ // We have a token, AND it's still valid
+ if valid_until > time::OffsetDateTime::now_utc() {
+ token
+ } else {
+ // token has expired. generate a new one, return it
+ let token = crypto_random_string::<24>();
+
+ sqlx::query("update user_verification_token set token = $2, valid_until = $3 where user_id=$1")
+ .bind(id)
+ .bind(&token)
+ .bind(time::OffsetDateTime::now_utc() + time::Duration::minutes(TOKEN_VALID_MINUTES))
+ .execute(&self.pool)
+ .await
+ .map_err(fix_error)?;
+
+ token
+ }
+ } else {
+ // No token in the database! Generate one, insert it
+ let token = crypto_random_string::<24>();
+
+ sqlx::query("insert into user_verification_token (user_id, token, valid_until) values ($1, $2, $3)")
+ .bind(id)
+ .bind(&token)
+ .bind(time::OffsetDateTime::now_utc() + time::Duration::minutes(TOKEN_VALID_MINUTES))
+ .execute(&self.pool)
+ .await
+ .map_err(fix_error)?;
+
+ token
+ };
+
+ Ok(token)
}
#[instrument(skip_all)]
async fn get_session_user(&self, token: &str) -> DbResult<User> {
sqlx::query_as(
- "select users.id, users.username, users.email, users.password from users
+ "select users.id, users.username, users.email, users.password, users.verified_at from users
inner join sessions
on users.id = sessions.user_id
and sessions.token = $1",
diff --git a/crates/atuin-server-postgres/src/wrappers.rs b/crates/atuin-server-postgres/src/wrappers.rs
index 3ccf9c19..80f95f21 100644
--- a/crates/atuin-server-postgres/src/wrappers.rs
+++ b/crates/atuin-server-postgres/src/wrappers.rs
@@ -16,6 +16,7 @@ impl<'a> FromRow<'a, PgRow> for DbUser {
username: row.try_get("username")?,
email: row.try_get("email")?,
password: row.try_get("password")?,
+ verified: row.try_get("verified_at")?,
}))
}
}
diff --git a/crates/atuin-server/Cargo.toml b/crates/atuin-server/Cargo.toml
index b076a466..089fe715 100644
--- a/crates/atuin-server/Cargo.toml
+++ b/crates/atuin-server/Cargo.toml
@@ -37,3 +37,4 @@ argon2 = "0.5"
semver = { workspace = true }
metrics-exporter-prometheus = "0.12.1"
metrics = "0.21.1"
+postmark = {version= "0.10.0", features=["reqwest"]}
diff --git a/crates/atuin-server/src/handlers/user.rs b/crates/atuin-server/src/handlers/user.rs
index 8e53dd49..7fd1a4a2 100644
--- a/crates/atuin-server/src/handlers/user.rs
+++ b/crates/atuin-server/src/handlers/user.rs
@@ -12,9 +12,11 @@ use axum::{
Json,
};
use metrics::counter;
+
+use postmark::{reqwest::PostmarkClient, Query};
+
use rand::rngs::OsRng;
use tracing::{debug, error, info, instrument};
-use uuid::Uuid;
use super::{ErrorResponse, ErrorResponseStatus, RespExt};
use crate::router::{AppState, UserAuth};
@@ -25,7 +27,7 @@ use atuin_server_database::{
use reqwest::header::CONTENT_TYPE;
-use atuin_common::api::*;
+use atuin_common::{api::*, utils::crypto_random_string};
pub fn verify_str(hash: &str, password: &str) -> bool {
let arg2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default());
@@ -126,7 +128,8 @@ pub async fn register<DB: Database>(
}
};
- let token = Uuid::new_v4().as_simple().to_string();
+ // 24 bytes encoded as base64
+ let token = crypto_random_string::<24>();
let new_session = NewSession {
user_id,
@@ -175,6 +178,107 @@ pub async fn delete<DB: Database>(
Ok(Json(DeleteUserResponse {}))
}
+#[instrument(skip_all, fields(user.id = user.id))]
+pub async fn send_verification<DB: Database>(
+ UserAuth(user): UserAuth,
+ state: State<AppState<DB>>,
+) -> Result<Json<SendVerificationResponse>, ErrorResponseStatus<'static>> {
+ let settings = state.0.settings;
+
+ if !settings.mail.enabled {
+ return Ok(Json(SendVerificationResponse {
+ email_sent: false,
+ verified: false,
+ }));
+ }
+
+ if user.verified.is_some() {
+ return Ok(Json(SendVerificationResponse {
+ email_sent: false,
+ verified: true,
+ }));
+ }
+
+ // TODO: if we ever add another mail provider, can match on them all here.
+ let postmark_token = if let Some(token) = settings.mail.postmark.token {
+ token
+ } else {
+ return Err(ErrorResponse::reply("mail not configured")
+ .with_status(StatusCode::INTERNAL_SERVER_ERROR));
+ };
+
+ let db = &state.0.database;
+
+ let verification_token = db
+ .user_verification_token(user.id)
+ .await
+ .expect("Failed to verify");
+
+ debug!("Generated verification token, emailing user");
+
+ let client = PostmarkClient::builder()
+ .base_url("https://api.postmarkapp.com/")
+ .token(postmark_token)
+ .build();
+
+ let req = postmark::api::email::SendEmailRequest::builder()
+ .from(settings.mail.verification.from)
+ .subject(settings.mail.verification.subject)
+ .to(user.email)
+ .body(postmark::api::Body::text(format!(
+ "Please run the following command to finalize your Atuin account verification. It is valid for 15 minutes:\n\natuin account verify --token '{}'",
+ verification_token
+ )))
+ .build();
+
+ req.execute(&client)
+ .await
+ .expect("postmark email request failed");
+
+ debug!("Email sent");
+
+ Ok(Json(SendVerificationResponse {
+ email_sent: true,
+ verified: false,
+ }))
+}
+
+#[instrument(skip_all, fields(user.id = user.id))]
+pub async fn verify_user<DB: Database>(
+ UserAuth(user): UserAuth,
+ state: State<AppState<DB>>,
+ Json(token_request): Json<VerificationTokenRequest>,
+) -> Result<Json<VerificationTokenResponse>, ErrorResponseStatus<'static>> {
+ let db = state.0.database;
+
+ if user.verified.is_some() {
+ return Ok(Json(VerificationTokenResponse { verified: true }));
+ }
+
+ let token = db.user_verification_token(user.id).await.map_err(|e| {
+ error!("Failed to read user token: {e}");
+
+ ErrorResponse::reply("Failed to verify").with_status(StatusCode::INTERNAL_SERVER_ERROR)
+ })?;
+
+ if token_request.token == token {
+ db.verify_user(user.id).await.map_err(|e| {
+ error!("Failed to verify user: {e}");
+
+ ErrorResponse::reply("Failed to verify").with_status(StatusCode::INTERNAL_SERVER_ERROR)
+ })?;
+ } else {
+ info!(
+ "Incorrect verification token {} vs {}",
+ token_request.token, token
+ );
+
+ return Ok(Json(VerificationTokenResponse { verified: false }));
+ }
+
+ Ok(Json(VerificationTokenResponse { verified: true }))
+}
+
#[instrument(skip_all, fields(user.id = user.id, change_password))]
pub async fn change_password<DB: Database>(
UserAuth(mut user): UserAuth,
diff --git a/crates/atuin-server/src/router.rs b/crates/atuin-server/src/router.rs
index 96dff2bd..df3a2937 100644
--- a/crates/atuin-server/src/router.rs
+++ b/crates/atuin-server/src/router.rs
@@ -126,6 +126,11 @@ pub fn router<DB: Database>(database: DB, settings: Settings<DB::Settings>) -> R
.route("/record", get(handlers::record::index::<DB>))
.route("/record/next", get(handlers::record::next))
.route("/api/v0/me", get(handlers::v0::me::get))
+ .route("/api/v0/account/verify", post(handlers::user::verify_user))
+ .route(
+ "/api/v0/account/send-verification",
+ post(handlers::user::send_verification),
+ )
.route("/api/v0/record", post(handlers::v0::record::post))
.route("/api/v0/record", get(handlers::v0::record::index))
.route("/api/v0/record/next", get(handlers::v0::record::next))
diff --git a/crates/atuin-server/src/settings.rs b/crates/atuin-server/src/settings.rs
index 286b5688..1246982a 100644
--- a/crates/atuin-server/src/settings.rs
+++ b/crates/atuin-server/src/settings.rs
@@ -7,8 +7,34 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize};
static EXAMPLE_CONFIG: &str = include_str!("../server.toml");
+#[derive(Default, Clone, Debug, Deserialize, Serialize)]
+pub struct Mail {
+ #[serde(alias = "enable")]
+ pub enabled: bool,
+
+ /// Configuration for the postmark api client
+ /// This is what we use for Atuin Cloud, the forum, etc.
+ pub postmark: Postmark,
+
+ pub verification: MailVerification,
+}
+
+#[derive(Default, Clone, Debug, Deserialize, Serialize)]
+pub struct Postmark {
+ #[serde(alias = "enable")]
+ pub token: Option<String>,
+}
+
+#[derive(Default, Clone, Debug, Deserialize, Serialize)]
+pub struct MailVerification {
+ #[serde(alias = "enable")]
+ pub from: String,
+ pub subject: String,
+}
+
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Metrics {
+ #[serde(alias = "enabled")]
pub enable: bool,
pub host: String,
pub port: u16,
@@ -37,6 +63,7 @@ pub struct Settings<DbSettings> {
pub register_webhook_username: String,
pub metrics: Metrics,
pub tls: Tls,
+ pub mail: Mail,
#[serde(flatten)]
pub db_settings: DbSettings,
diff --git a/crates/atuin/src/command/client/account.rs b/crates/atuin/src/command/client/account.rs
index e99c9593..011d7c69 100644
--- a/crates/atuin/src/command/client/account.rs
+++ b/crates/atuin/src/command/client/account.rs
@@ -9,6 +9,7 @@ pub mod delete;
pub mod login;
pub mod logout;
pub mod register;
+pub mod verify;
#[derive(Args, Debug)]
pub struct Cmd {
@@ -32,6 +33,9 @@ pub enum Commands {
/// Change your password
ChangePassword(change_password::Cmd),
+
+ /// Verify your account
+ Verify(verify::Cmd),
}
impl Cmd {
@@ -42,6 +46,7 @@ impl Cmd {
Commands::Logout => logout::run(&settings),
Commands::Delete => delete::run(&settings).await,
Commands::ChangePassword(c) => c.run(&settings).await,
+ Commands::Verify(c) => c.run(&settings).await,
}
}
}
diff --git a/crates/atuin/src/command/client/account/verify.rs b/crates/atuin/src/command/client/account/verify.rs
new file mode 100644
index 00000000..f803bd3d
--- /dev/null
+++ b/crates/atuin/src/command/client/account/verify.rs
@@ -0,0 +1,47 @@
+use clap::Parser;
+use eyre::Result;
+
+use atuin_client::{api_client, settings::Settings};
+
+#[derive(Parser, Debug)]
+pub struct Cmd {
+ #[clap(long, short)]
+ pub token: Option<String>,
+}
+
+impl Cmd {
+ pub async fn run(self, settings: &Settings) -> Result<()> {
+ run(settings, self.token).await
+ }
+}
+
+pub async fn run(settings: &Settings, token: Option<String>) -> Result<()> {
+ let client = api_client::Client::new(
+ &settings.sync_address,
+ settings.session_token()?.as_str(),
+ settings.network_connect_timeout,
+ settings.network_timeout,
+ )?;
+
+ let (email_sent, verified) = client.verify(token).await?;
+
+ match (email_sent, verified) {
+ (true, false) => {
+ println!("Verification sent! Please check your inbox");
+ }
+
+ (false, true) => {
+ println!("Your account is verified");
+ }
+
+ (false, false) => {
+ println!("Your Atuin server does not have mail setup. This is not required, though your account cannot be verified. Speak to your admin.");
+ }
+
+ _ => {
+ println!("Invalid email and verification status. This is a bug. Please open an issue: https://github.com/atuinsh/atuin");
+ }
+ }
+
+ Ok(())
+}
diff --git a/crates/atuin/tests/common/mod.rs b/crates/atuin/tests/common/mod.rs
index 65679244..84e3cea6 100644
--- a/crates/atuin/tests/common/mod.rs
+++ b/crates/atuin/tests/common/mod.rs
@@ -38,6 +38,7 @@ pub async fn start_server(path: &str) -> (String, oneshot::Sender<()>, JoinHandl
db_settings: PostgresSettings { db_uri },
metrics: atuin_server::settings::Metrics::default(),
tls: atuin_server::settings::Tls::default(),
+ mail: atuin_server::settings::Mail::default(),
};
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();