diff --git a/Cargo.lock b/Cargo.lock index dc8e1a0..a301f67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -968,9 +968,12 @@ dependencies = [ "dashmap", "directories", "dotenv", + "hyper", + "hyper-tls", "rand 0.8.5", "rocket", "sdk", + "serde_urlencoded", "toml 0.7.2", ] @@ -1658,9 +1661,9 @@ checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" [[package]] name = "hyper" -version = "0.14.24" +version = "0.14.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e011372fa0b68db8350aa7a248930ecc7839bf46d8485577d69f117a75f164c" +checksum = "cc5e554ff619822309ffd57d8734d77cd5ce6238bc956f037ea06c58238c9899" dependencies = [ "bytes", "futures-channel", diff --git a/README.md b/README.md index 16e95f7..cf6bae8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@
Deploy Recent commit - Mail Discover Downloads Docker Image Size (tag) Discord MIT license diff --git a/apps/server/Cargo.toml b/apps/server/Cargo.toml index 3699261..72a5e1b 100644 --- a/apps/server/Cargo.toml +++ b/apps/server/Cargo.toml @@ -16,3 +16,7 @@ dashmap = "5.4.0" dotenv = "0.15.0" base64 = "0.21" rand = "0.8.5" + +hyper = { version = "0.14.25", features = ["full"] } +hyper-tls = "0.5.0" +serde_urlencoded = "0.7.1" diff --git a/apps/server/src/guards/user.rs b/apps/server/src/guards/user.rs index b483f0f..4b6e558 100644 --- a/apps/server/src/guards/user.rs +++ b/apps/server/src/guards/user.rs @@ -12,25 +12,17 @@ use crate::{ }; pub struct User { - token: String, mail_sessions: Arc, } impl User { - pub fn new>(token: S, mail_sessions: Arc) -> Self { - User { - mail_sessions, - token: token.into(), - } + pub fn new(mail_sessions: Arc) -> Self { + User { mail_sessions } } pub fn mail_sessions(&self) -> Arc { self.mail_sessions.clone() } - - pub fn token(&self) -> &str { - &self.token - } } #[rocket::async_trait] @@ -54,7 +46,7 @@ impl<'r> FromRequest<'r> for User { Some(user_sessions) => { let user_session = user_sessions.get(&token); - Outcome::Success(User::new(token, user_session)) + Outcome::Success(User::new(user_session)) } None => { let error = ErrResponse::new( diff --git a/apps/server/src/http.rs b/apps/server/src/http.rs new file mode 100644 index 0000000..0fc54fe --- /dev/null +++ b/apps/server/src/http.rs @@ -0,0 +1,54 @@ +use hyper::{body, client::HttpConnector, Body, Client, Request}; +use hyper_tls::HttpsConnector; + +use crate::types::{Error, ErrorKind, Result}; + +pub struct HttpClient { + client: Client, + client_tls: Client>, +} + +impl HttpClient { + pub fn new() -> Self { + let http_connector = HttpConnector::new(); + let client = Client::builder().build(http_connector); + + let https_connector = HttpsConnector::new(); + let client_tls = Client::builder().build(https_connector); + + Self { client, client_tls } + } + pub async fn request(&self, req: Request) -> Result> { + let secure = match req.uri().scheme() { + Some(scheme) => scheme.as_str() == "https", + None => { + return Err(Error::new( + ErrorKind::CreateHttpRequest, + "Missing request scheme", + )) + } + }; + + let response_result = if secure { + self.client_tls.request(req).await + } else { + self.client.request(req).await + }; + + let response = response_result.map_err(|err| { + Error::new( + ErrorKind::CreateHttpRequest, + format!("Failed to create http request: {}", err), + ) + })?; + + let body = body::to_bytes(response.into_body()).await.map_err(|err| { + Error::new( + ErrorKind::CreateHttpRequest, + format!("Failed to read response body in http request: {}", err), + ) + })?; + + Ok(Vec::from(body)) + } +} diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 3c2b486..18be33e 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -2,6 +2,8 @@ mod cache; mod constants; mod fairings; mod guards; +mod http; +mod oauth2; mod routes; mod state; mod types; @@ -27,6 +29,11 @@ fn too_many_requests() -> (Status, Json) { ) } +#[catch(401)] +fn unauthorized() -> (Status, Json) { + ErrResponse::new(ErrorKind::Unauthorized, "Unauthorized") +} + #[catch(500)] fn internal_error() -> (Status, Json) { ErrResponse::new(ErrorKind::InternalError, "Internal server error") @@ -65,9 +72,15 @@ fn rocket() -> _ { let mail_sessions_state = state::GlobalUserSessions::new(); + let http_client = http::HttpClient::new(); + rocket::custom(figment) - .register("/", catchers![not_found, internal_error, too_many_requests]) + .register( + "/", + catchers![not_found, internal_error, too_many_requests, unauthorized], + ) .manage(config) + .manage(http_client) .manage(ip_state) .manage(cache_state) .manage(mail_sessions_state) @@ -94,4 +107,11 @@ fn rocket() -> _ { routes::mail_box_message_handler ], ) + .mount( + "/mail/oauth2", + routes![ + routes::oauth_get_tokens_handler, + routes::oauth_redirect_handler + ], + ) } diff --git a/apps/server/src/oauth2.rs b/apps/server/src/oauth2.rs new file mode 100644 index 0000000..367856b --- /dev/null +++ b/apps/server/src/oauth2.rs @@ -0,0 +1,92 @@ +use std::collections::HashMap; + +use crate::{ + http::HttpClient, + types::{Error, ErrorKind, Result}, +}; + +use hyper::{header::CONTENT_TYPE, Body, Request}; +use rocket::serde::{json::from_slice as parse_json_from_slice, Deserialize}; + +#[derive(Deserialize)] +#[serde(crate = "rocket::serde")] +pub struct AccessTokenResponse { + access_token: String, + token_type: String, + expires_in: u16, + refresh_token: Option, +} + +impl AccessTokenResponse { + pub fn access_token(&self) -> &str { + &self.access_token + } +} + +pub async fn get_access_token>( + http_client: &HttpClient, + token_url: S, + code: S, + redirect_uri: S, + client_id: S, + client_secret: &Option, +) -> Result { + let mut form_data = HashMap::new(); + + form_data.insert("grant_type", "authorization_code"); + form_data.insert("code", code.as_ref()); + form_data.insert("redirect_uri", redirect_uri.as_ref()); + form_data.insert("client_id", client_id.as_ref()); + + match client_secret.as_ref() { + Some(client_secret) => { + form_data.insert("client_secret", client_secret.as_ref()); + } + None => {} + } + + let encoded_form_data = serde_urlencoded::to_string(form_data).map_err(|err| { + Error::new( + ErrorKind::CreateHttpRequest, + format!("Failed to create http request for oauth token: {}", err), + ) + })?; + + let url: hyper::Uri = token_url.as_ref().parse().map_err(|_| { + Error::new( + ErrorKind::BadConfig, + "Failed to parse token url from config", + ) + })?; + + let request = Request::builder() + .uri(url) + .method("POST") + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(encoded_form_data)) + .map_err(|_| { + Error::new( + ErrorKind::CreateHttpRequest, + "Failed to create http request to request oauth token", + ) + })?; + + let token_response = http_client.request(request).await.map_err(|err| { + Error::new( + ErrorKind::InternalError, + format!("Failed to request oauth access token: {}", err), + ) + })?; + + // println!("{}", String::from_utf8(token_response.clone()).unwrap()); + + let access_token_response: AccessTokenResponse = parse_json_from_slice(&token_response) + .map_err(|err| { + Error::new( + ErrorKind::Parse, + format!("Invalid response when fetching oauth access token: {}", err), + ) + })?; + + Ok(access_token_response) +} diff --git a/apps/server/src/routes/mail/login.rs b/apps/server/src/routes/mail/login.rs index 1ded68e..3c1f4ca 100644 --- a/apps/server/src/routes/mail/login.rs +++ b/apps/server/src/routes/mail/login.rs @@ -6,11 +6,11 @@ use crate::{ }; use rocket::{serde::json::Json, State}; -use sdk::session::{create_sessions, Credentials}; +use sdk::session::{create_sessions, FullLoginOptions}; #[post("/login", data = "")] pub async fn login( - credentials: Json, + credentials: Json, user: User, _rate_limiter: RateLimiter, config: &State, diff --git a/apps/server/src/routes/mail/mod.rs b/apps/server/src/routes/mail/mod.rs index f499e56..6fd28c0 100644 --- a/apps/server/src/routes/mail/mod.rs +++ b/apps/server/src/routes/mail/mod.rs @@ -1,7 +1,9 @@ mod boxes; mod login; mod logout; +mod oauth2; pub use boxes::*; pub use login::login as mail_login_handler; pub use logout::logout as mail_logout_handler; +pub use oauth2::*; diff --git a/apps/server/src/routes/mail/oauth2/mod.rs b/apps/server/src/routes/mail/oauth2/mod.rs new file mode 100644 index 0000000..2dc0469 --- /dev/null +++ b/apps/server/src/routes/mail/oauth2/mod.rs @@ -0,0 +1,5 @@ +mod redirect; +mod tokens; + +pub use redirect::handle_redirect as oauth_redirect_handler; +pub use tokens::get_tokens as oauth_get_tokens_handler; diff --git a/apps/server/src/routes/mail/oauth2/redirect.rs b/apps/server/src/routes/mail/oauth2/redirect.rs new file mode 100644 index 0000000..aa37dce --- /dev/null +++ b/apps/server/src/routes/mail/oauth2/redirect.rs @@ -0,0 +1,85 @@ +use rocket::{ + serde::{json::Json, Deserialize}, + State, +}; + +use crate::{ + http::HttpClient, + oauth2::get_access_token, + state::Config, + types::{ErrResponse, ErrorKind, OkResponse, ResponseResult}, +}; + +#[derive(Deserialize)] +#[serde(crate = "rocket::serde", rename_all = "camelCase")] +pub enum ApplicationType { + Desktop, + Web, +} + +#[derive(Deserialize)] +#[serde(crate = "rocket::serde", rename_all = "camelCase")] +pub struct OAuthState { + provider: String, + application: ApplicationType, +} + +impl OAuthState { + fn provider(&self) -> &str { + &self.provider + } + + fn application_type(&self) -> &ApplicationType { + &self.application + } +} + +#[get("/redirect?&&&")] +pub async fn handle_redirect( + code: Option, + state: Json, + scope: Option, + error: Option, + config: &State, + http_client: &State, +) -> ResponseResult { + if code.is_some() && scope.is_some() { + let provider = match config.oauth2().providers().get(state.provider()) { + Some(provider) => provider, + None => { + return Err(ErrResponse::new( + ErrorKind::BadRequest, + "Could not find requested oauth provider", + )) + } + }; + + let redirect_uri = format!("{}/mail/oauth2/redirect", config.external_host()); + let token_url = provider.token_url(); + let secret_token = provider.secret_token(); + let public_token = provider.public_token(); + let code = code.unwrap(); + + let access_token_response = get_access_token( + &http_client, + token_url, + code.as_str(), + redirect_uri.as_str(), + public_token, + secret_token, + ) + .await + .map_err(|err| ErrResponse::from(err).into())?; + + println!("{}", access_token_response.access_token()); + + Ok(OkResponse::new(token_url.to_string())) + } else if error.is_some() { + Err(ErrResponse::new(ErrorKind::BadRequest, "yeet")) + } else { + Err(ErrResponse::new( + ErrorKind::BadRequest, + "Missing required params", + )) + } +} diff --git a/apps/server/src/routes/mail/oauth2/tokens.rs b/apps/server/src/routes/mail/oauth2/tokens.rs new file mode 100644 index 0000000..8200d44 --- /dev/null +++ b/apps/server/src/routes/mail/oauth2/tokens.rs @@ -0,0 +1,20 @@ +use std::collections::HashMap; + +use rocket::State; + +use crate::{ + state::Config, + types::{OkResponse, ResponseResult}, +}; + +#[get("/tokens")] +pub fn get_tokens(config: &State) -> ResponseResult> { + let public_tokens: HashMap = config + .oauth2() + .providers() + .iter() + .map(|(key, value)| return (key.to_string(), value.public_token().to_string())) + .collect(); + + Ok(OkResponse::new(public_tokens)) +} diff --git a/apps/server/src/state/config/mod.rs b/apps/server/src/state/config/mod.rs index d18f8ac..49e3204 100644 --- a/apps/server/src/state/config/mod.rs +++ b/apps/server/src/state/config/mod.rs @@ -2,6 +2,7 @@ mod appearance; mod authorization; mod cache; mod limit; +mod oauth2; use rocket::serde::{Deserialize, Serialize}; @@ -14,6 +15,8 @@ use limit::RateLimit; pub use authorization::{default_expiry_time, AuthType}; +use self::oauth2::OAuth2; + #[derive(Deserialize, Serialize)] #[serde(crate = "rocket::serde")] pub struct Config { @@ -23,12 +26,15 @@ pub struct Config { host: String, #[serde(default = "behind_proxy")] behind_proxy: bool, + external_host: String, #[serde(default)] rate_limit: RateLimit, #[serde(default)] appearance: Appearance, #[serde(default)] cache: Cache, + #[serde(default)] + oauth2: OAuth2, auth: Option, } @@ -45,6 +51,10 @@ impl Config { &self.behind_proxy } + pub fn external_host(&self) -> &str { + &self.external_host + } + pub fn rate_limit(&self) -> &RateLimit { &self.rate_limit } @@ -60,6 +70,10 @@ impl Config { pub fn authorization(&self) -> Option<&Authorization> { self.auth.as_ref() } + + pub fn oauth2(&self) -> &OAuth2 { + &self.oauth2 + } } impl Default for Config { @@ -72,10 +86,12 @@ impl Default for Config { Self { appearance: Appearance::default(), + external_host: String::from("https://example.com"), auth, rate_limit: RateLimit::default(), cache: Cache::default(), behind_proxy: behind_proxy(), + oauth2: OAuth2::default(), host: default_host(), port: default_port(), } diff --git a/apps/server/src/state/config/oauth2.rs b/apps/server/src/state/config/oauth2.rs new file mode 100644 index 0000000..1fe157a --- /dev/null +++ b/apps/server/src/state/config/oauth2.rs @@ -0,0 +1,45 @@ +use std::collections::HashMap; + +use rocket::serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize)] +#[serde(crate = "rocket::serde")] +pub struct OAuth2 { + providers: HashMap, +} + +impl OAuth2 { + pub fn providers(&self) -> &HashMap { + &self.providers + } +} + +impl Default for OAuth2 { + fn default() -> Self { + Self { + providers: HashMap::new(), + } + } +} + +#[derive(Deserialize, Serialize)] +#[serde(crate = "rocket::serde")] +pub struct Provider { + public_token: String, + secret_token: Option, + token_url: String, +} + +impl Provider { + pub fn public_token(&self) -> &str { + &self.public_token + } + + pub fn secret_token(&self) -> &Option { + &self.secret_token + } + + pub fn token_url(&self) -> &str { + &self.token_url + } +} diff --git a/apps/server/src/types/error.rs b/apps/server/src/types/error.rs index f67f280..a97f268 100644 --- a/apps/server/src/types/error.rs +++ b/apps/server/src/types/error.rs @@ -7,6 +7,8 @@ use sdk::types::Error as SdkError; #[serde(crate = "rocket::serde")] pub enum ErrorKind { SdkError(SdkError), + CreateHttpRequest, + BadConfig, Unauthorized, BadRequest, TooManyRequests, diff --git a/apps/server/src/types/response.rs b/apps/server/src/types/response.rs index e363cf6..1b92720 100644 --- a/apps/server/src/types/response.rs +++ b/apps/server/src/types/response.rs @@ -28,6 +28,8 @@ pub struct ErrResponse { impl ErrResponse { fn find_status_from_error_kind(error_kind: &ErrorKind) -> Status { match error_kind { + ErrorKind::BadConfig => Status::InternalServerError, + ErrorKind::CreateHttpRequest => Status::InternalServerError, ErrorKind::Unauthorized => Status::Unauthorized, ErrorKind::BadRequest => Status::BadRequest, ErrorKind::TooManyRequests => Status::TooManyRequests, diff --git a/apps/web/src-tauri/src/commands.rs b/apps/web/src-tauri/src/commands.rs index 47b99b6..1580a8b 100644 --- a/apps/web/src-tauri/src/commands.rs +++ b/apps/web/src-tauri/src/commands.rs @@ -1,10 +1,13 @@ use sdk::{ detect::{self, Config}, - session::Credentials, + session::FullLoginOptions, types::{MailBox, Message, Preview}, }; -use crate::{types::{session, Result, Sessions}, files::CacheFile}; +use crate::{ + files::CacheFile, + types::{session, Result, Sessions}, +}; use tauri::{async_runtime::spawn_blocking, State}; @@ -15,7 +18,7 @@ pub async fn detect_config(email_address: String) -> Result { #[tauri::command(async)] pub async fn login( - credentials: Credentials, + credentials: FullLoginOptions, session_handler: State<'_, Sessions>, ) -> Result { // Connect and login to the mail servers using the user provided credentials. @@ -39,9 +42,7 @@ pub async fn list(token: String, sessions: State<'_, Sessions>) -> Result) - let fetch_box = spawn_blocking(move || { let mut session_lock = session.lock().unwrap(); - let mailbox = session_lock - .get(&box_id) - .map(|mailbox| mailbox.clone())?; + let mailbox = session_lock.get(&box_id).map(|mailbox| mailbox.clone())?; Ok(mailbox) }); @@ -81,8 +80,7 @@ pub async fn messages( let fetch_message_list = spawn_blocking(move || { let mut session_lock = session.lock().unwrap(); - let message_list = session_lock - .messages(&box_id, start, end)?; + let message_list = session_lock.messages(&box_id, start, end)?; Ok(message_list) }); @@ -103,8 +101,7 @@ pub async fn get_message( let fetch_message = spawn_blocking(move || { let mut session_lock = session.lock().unwrap(); - let message = session_lock - .get_message(&box_id, &message_id)?; + let message = session_lock.get_message(&box_id, &message_id)?; Ok(message) }); diff --git a/apps/web/src-tauri/src/types/session/utils.rs b/apps/web/src-tauri/src/types/session/utils.rs index 78471b5..b120084 100644 --- a/apps/web/src-tauri/src/types/session/utils.rs +++ b/apps/web/src-tauri/src/types/session/utils.rs @@ -1,9 +1,10 @@ -use sdk::session::Credentials; +use sdk::session::FullLoginOptions; use crate::{ base64, cryptography, files::CacheFile, - types::{Error, ErrorKind, Result}, parse, + parse, + types::{Error, ErrorKind, Result}, }; /// Given a token, get the encryption nonce and key. @@ -34,7 +35,7 @@ pub fn get_nonce_and_key_from_token(token: &str) -> Result<(String, String)> { } /// Given a token, find the corresponding encrypted login credentials in the cache dir and return them. -pub fn get_credentials(token: &str) -> Result { +pub fn get_credentials(token: &str) -> Result { let (key_base64, nonce_base64) = get_nonce_and_key_from_token(token)?; let key = base64::decode(key_base64.as_bytes())?; @@ -56,7 +57,7 @@ pub fn get_credentials(token: &str) -> Result { let decrypted = cryptography::decrypt(&encrypted, &key, &nonce)?; - let login_options: Credentials = serde_json::from_slice(&decrypted).map_err(|e| { + let login_options: FullLoginOptions = serde_json::from_slice(&decrypted).map_err(|e| { Error::new( ErrorKind::DeserializeJSON, format!("Could not deserialize encrypted login info {}", e), @@ -66,7 +67,7 @@ pub fn get_credentials(token: &str) -> Result { Ok(login_options) } -pub fn generate_token(credentials: &Credentials) -> Result { +pub fn generate_token(credentials: &FullLoginOptions) -> Result { // Serialize the given options to json let options_json = parse::to_json(credentials)?; diff --git a/apps/web/src/components/Login/Form.tsx b/apps/web/src/components/Login/Form.tsx index c8f6404..ee03d49 100644 --- a/apps/web/src/components/Login/Form.tsx +++ b/apps/web/src/components/Login/Form.tsx @@ -43,16 +43,16 @@ import Typography from "@mui/material/Typography"; import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; -import MultiServerLoginOptions from "@interfaces/login"; - import modalStyles from "@styles/modal"; import scrollbarStyles from "@styles/scrollbar"; import { useMailLogin } from "@utils/hooks/useLogin"; import useMailClient from "@utils/hooks/useMailClient"; import useMultiServerLoginStore, { - defaultConfigs + defaultConfigs, + MultiServerLoginOptions } from "@utils/hooks/useMultiServerLoginStore"; +import useOAuth2Client from "@utils/hooks/useOAuth2Client"; import useStore from "@utils/hooks/useStore"; import useTheme from "@utils/hooks/useTheme"; import parseEmail from "@utils/parseEmail"; @@ -263,6 +263,32 @@ const UnMemoizedServerConfigColumn: FC<{ const ServerConfigColumn = memo(UnMemoizedServerConfigColumn); +const createCredentials = ( + incomingConfig: MultiServerLoginOptions, + incomingType: IncomingMailServerType +): Credentials => { + const { + username: incomingUsername, + password: incomingPassword, + ...incoming + } = incomingConfig; + + const options: Credentials = { + incoming: { + ...incoming, + loginType: { + passwordBased: { + password: incomingPassword, + username: incomingUsername + } + } + }, + incomingType + }; + + return options; +}; + const LoginOptionsMenu: FC = () => { const theme = useTheme(); @@ -287,10 +313,10 @@ const LoginOptionsMenu: FC = () => { const provider = useMultiServerLoginStore((state) => state.provider); - const incoming = useMultiServerLoginStore( + const incomingConfig = useMultiServerLoginStore( (state) => state.incoming[selectedMailServerTypes.incoming] ); - const outgoing = useMultiServerLoginStore( + const outgoingConfig = useMultiServerLoginStore( (state) => state.outgoing[selectedMailServerTypes.outgoing] ); @@ -304,8 +330,8 @@ const LoginOptionsMenu: FC = () => { }, [resetToDefaults, setOpen]); const missingFields = useMemo(() => { - return !incoming.username || !incoming.password; - }, [incoming.username, incoming.password]); + return !incomingConfig.username || !incomingConfig.password; + }, [incomingConfig.username, incomingConfig.password]); const onSubmit: FormEventHandler = async (e): Promise => { e.preventDefault(); @@ -316,12 +342,12 @@ const LoginOptionsMenu: FC = () => { return; } - const options: Credentials = { - incoming, - incoming_type: selectedMailServerTypes.incoming - }; + const credentials = createCredentials( + incomingConfig, + selectedMailServerTypes.incoming + ); - await login(options) + await login(credentials) .then((result) => { if (result.ok) { onClose(); @@ -362,21 +388,21 @@ const LoginOptionsMenu: FC = () => { @@ -433,6 +459,7 @@ const LoginForm: FC<{ const [error, setError] = useState(); const mailClient = useMailClient(); + const oauthClient = useOAuth2Client(); useEffect(() => setError(undefined), [username, password]); @@ -509,6 +536,13 @@ const LoginForm: FC<{ const config = configResult.data; + oauthClient.getGrant( + config.displayName, + config.oauth2?.oauthUrl ?? "", + config.oauth2?.tokenUrl ?? "", + config.oauth2?.scopes ?? [] + ); + if ( typeof config.type != "string" && config.type.multiServer?.incoming && @@ -517,7 +551,6 @@ const LoginForm: FC<{ const incomingConfigs: (MultiServerLoginOptions & { type: IncomingMailServerType; })[] = config.type.multiServer.incoming.map( - // eslint-disable-next-line @typescript-eslint/no-unused-vars ({ authType, ...config }) => ({ ...config, loginType: authType, @@ -529,7 +562,6 @@ const LoginForm: FC<{ const outgoingConfigs: (MultiServerLoginOptions & { type: OutgoingMailServerType; })[] = config.type.multiServer.outgoing.map( - // eslint-disable-next-line @typescript-eslint/no-unused-vars ({ authType, ...config }) => ({ ...config, loginType: authType, diff --git a/apps/web/src/interfaces/login.ts b/apps/web/src/interfaces/login.ts deleted file mode 100644 index d5092a0..0000000 --- a/apps/web/src/interfaces/login.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { AuthType, ConnectionSecurity } from "@dust-mail/structures"; - -export default interface MultiServerLoginOptions { - username: string; - password: string; - domain: string; - port: number; - security: ConnectionSecurity; - loginType: AuthType[]; -} diff --git a/apps/web/src/interfaces/oauth2.ts b/apps/web/src/interfaces/oauth2.ts new file mode 100644 index 0000000..a2b058d --- /dev/null +++ b/apps/web/src/interfaces/oauth2.ts @@ -0,0 +1,10 @@ +import { Result } from "./result"; + +export default interface OAuth2Client { + getGrant: ( + providerName: string, + grantUrl: string, + tokenUrl: string, + scopes: string[] + ) => Promise>; +} diff --git a/apps/web/src/utils/avatarUrl.ts b/apps/web/src/utils/avatarUrl.ts index e5f84c3..c2bdc34 100644 --- a/apps/web/src/utils/avatarUrl.ts +++ b/apps/web/src/utils/avatarUrl.ts @@ -3,14 +3,12 @@ import createMd5Hash from "js-md5"; const createAvatarUrl = (email: string): string => { const hashed = createMd5Hash(email.trim().toLowerCase()); - const searchParams = new URLSearchParams(); + const url = new URL("https://www.gravatar.com/avatar/" + hashed); - searchParams.set("s", "64"); - searchParams.set("d", "404"); + url.searchParams.set("s", "64"); + url.searchParams.set("d", "404"); - return ( - "https://www.gravatar.com/avatar/" + hashed + "?" + searchParams.toString() - ); + return url.toString(); }; export default createAvatarUrl; diff --git a/apps/web/src/utils/defaultErrors.ts b/apps/web/src/utils/defaultErrors.ts new file mode 100644 index 0000000..cc20560 --- /dev/null +++ b/apps/web/src/utils/defaultErrors.ts @@ -0,0 +1,23 @@ +import { createBaseError } from "./parseError"; + +import { ErrorResult } from "@interfaces/result"; + +export const NotLoggedIn = (): ErrorResult => + createBaseError({ + kind: "NotLoggedIn", + message: "Could not find session token in local storage" + }); + +export const NotImplemented = (feature?: string): ErrorResult => + createBaseError({ + kind: "NotImplemented", + message: `The feature ${ + feature ? `'${feature}'` : "" + } is not yet implemented` + }); + +export const MissingRequiredParam = (): ErrorResult => + createBaseError({ + kind: "MissingRequiredParam", + message: "Missing a required parameter" + }); diff --git a/apps/web/src/utils/hooks/useLogin.ts b/apps/web/src/utils/hooks/useLogin.ts index f350322..1fa37fe 100644 --- a/apps/web/src/utils/hooks/useLogin.ts +++ b/apps/web/src/utils/hooks/useLogin.ts @@ -3,7 +3,12 @@ import useUser, { useCurrentUser, useModifyUser } from "./useUser"; import { useNavigate } from "react-router-dom"; -import { Credentials, ServerType, Version } from "@dust-mail/structures"; +import { + Credentials, + LoginOptions, + ServerType, + Version +} from "@dust-mail/structures"; import { Result } from "@interfaces/result"; @@ -16,6 +21,29 @@ import { // TODO: Fix this mess of a file. +const findUsernameInLoginOptions = (loginOptions: LoginOptions): string => { + return ( + loginOptions.loginType.oAuthBased?.username ?? + loginOptions.loginType.passwordBased?.username ?? + // Should never occur, but just in case it does happen there will be a fallback + "test@example.com" + ); +}; + +const createIdentifier = ( + incomingConfig: LoginOptions, + outgoingConfig: LoginOptions +): string => { + const incomingUsername = findUsernameInLoginOptions(incomingConfig); + const outgoingUsername = findUsernameInLoginOptions(outgoingConfig); + + const identifier = btoa( + `${incomingUsername}@${incomingConfig.domain}|${outgoingUsername}@${outgoingConfig.domain}` + ); + + return identifier; +}; + export const useMailLogin = (): (( config: Credentials ) => Promise>) => { @@ -80,15 +108,13 @@ export const useMailLogin = (): (( message: "Config is missing items" }); - const id = btoa( - `${incomingConfig.username}@${incomingConfig.domain}|${outgoingConfig.username}@${outgoingConfig.domain}` - ); + const id = createIdentifier(incomingConfig, outgoingConfig); login(loginResult.data, { id, usernames: { - incoming: incomingConfig.username, - outgoing: outgoingConfig.username + incoming: findUsernameInLoginOptions(incomingConfig), + outgoing: findUsernameInLoginOptions(outgoingConfig) }, redirectToDashboard: true, setAsDefault: true diff --git a/apps/web/src/utils/hooks/useMailClient.ts b/apps/web/src/utils/hooks/useMailClient.ts index c355145..cd040fe 100644 --- a/apps/web/src/utils/hooks/useMailClient.ts +++ b/apps/web/src/utils/hooks/useMailClient.ts @@ -17,8 +17,8 @@ import { import { messageCountForPage } from "@src/constants"; import MailClient from "@interfaces/client"; -import { ErrorResult } from "@interfaces/result"; +import { MissingRequiredParam, NotLoggedIn } from "@utils/defaultErrors"; import parseEmail from "@utils/parseEmail"; import { createBaseError, @@ -27,26 +27,6 @@ import { } from "@utils/parseError"; import parseZodOutput from "@utils/parseZodOutput"; -const NotLoggedIn = (): ErrorResult => - createBaseError({ - kind: "NotLoggedIn", - message: "Could not find session token in local storage" - }); - -// const NotImplemented = (feature?: string): Error => -// createBaseError({ -// kind: "NotImplemented", -// message: `The feature ${ -// feature ? `'${feature}'` : "" -// } is not yet implemented` -// }); - -const MissingRequiredParam = (): ErrorResult => - createBaseError({ - kind: "MissingRequiredParam", - message: "Missing a required parameter" - }); - const useMailClient = (): MailClient => { const isTauri: boolean = "__TAURI__" in window; diff --git a/apps/web/src/utils/hooks/useMultiServerLoginStore.ts b/apps/web/src/utils/hooks/useMultiServerLoginStore.ts index 8fbaaea..ee1da51 100644 --- a/apps/web/src/utils/hooks/useMultiServerLoginStore.ts +++ b/apps/web/src/utils/hooks/useMultiServerLoginStore.ts @@ -10,10 +10,13 @@ import { ServerType, MailServerType, ConnectionSecurity, - AuthType + LoginOptions } from "@dust-mail/structures"; -import MultiServerLoginOptions from "@interfaces/login"; +export type MultiServerLoginOptions = { + password: string; + username: string; +} & Omit; type Store = Record< IncomingServerType, @@ -49,57 +52,51 @@ type Store = Record< ) => (newValue?: string | number) => void; }; -export const defaultIncomingServer: IncomingMailServerType = "Imap"; -export const defaultOutgoingServer: OutgoingMailServerType = "Smtp"; +export const defaultIncomingServer: IncomingMailServerType = "Imap" as const; +export const defaultOutgoingServer: OutgoingMailServerType = "Smtp" as const; -export const defaultUsername = ""; -export const defaultPassword = ""; +export const defaultUsername = "" as const; +export const defaultPassword = "" as const; export const defaultPorts: Record = { Imap: 993, Pop: 995, Exchange: 443, Smtp: 465 -}; +} as const; export const defaultSecuritySetting: ConnectionSecurity = "Tls"; -const defaultLoginTypes: AuthType[] = ["ClearText"]; - const defaultImapConfig: MultiServerLoginOptions = { - password: defaultPassword, username: defaultUsername, + password: defaultPassword, domain: "imap.example.com", security: defaultSecuritySetting, - port: defaultPorts["Imap"], - loginType: defaultLoginTypes + port: defaultPorts["Imap"] }; const defaultExchangeConfig: MultiServerLoginOptions = { - password: defaultPassword, username: defaultUsername, + password: defaultPassword, domain: "exchange.example.com", security: defaultSecuritySetting, - port: defaultPorts["Exchange"], - loginType: defaultLoginTypes + port: defaultPorts["Exchange"] }; const defaultPopConfig: MultiServerLoginOptions = { - password: defaultPassword, username: defaultUsername, + password: defaultPassword, domain: "pop.example.com", security: defaultSecuritySetting, - port: defaultPorts["Pop"], - loginType: defaultLoginTypes + port: defaultPorts["Pop"] }; const defaultSmtpConfig: MultiServerLoginOptions = { - password: defaultPassword, username: defaultUsername, + password: defaultPassword, domain: "smtp.example.com", security: defaultSecuritySetting, - port: defaultPorts["Smtp"], - loginType: defaultLoginTypes + port: defaultPorts["Smtp"] }; export const defaultConfigs = { diff --git a/apps/web/src/utils/hooks/useOAuth2Client.ts b/apps/web/src/utils/hooks/useOAuth2Client.ts new file mode 100644 index 0000000..74e7048 --- /dev/null +++ b/apps/web/src/utils/hooks/useOAuth2Client.ts @@ -0,0 +1,143 @@ +import z from "zod"; + +import useFetchClient from "./useFetchClient"; +import useSettings from "./useSettings"; + +import { OAuthState } from "@dust-mail/structures"; + +import OAuth2Client from "@interfaces/oauth2"; +import { Result } from "@interfaces/result"; + +import { NotImplemented } from "@utils/defaultErrors"; +import { createBaseError, createResultFromUnknown } from "@utils/parseError"; +import parseZodOutput from "@utils/parseZodOutput"; + +const useGetPublicOAuthTokens = (): (() => Promise< + Result> +>) => { + const fetch = useFetchClient(); + + return () => + fetch("/mail/oauth2/tokens") + .then((response) => { + if (!response.ok) { + return response; + } + + const output = z + .record(z.string(), z.string()) + .safeParse(response.data); + + return parseZodOutput(output); + }) + .catch(createResultFromUnknown); +}; + +const findProviderToken = ( + providerName: string, + tokens: Record +): [string, string] | null => { + for (const [key, value] of Object.entries(tokens)) { + const isProvider = providerName + .toLowerCase() + .includes(key.trim().toLowerCase()); + + if (isProvider) return [value, key]; + } + + return null; +}; + +const useOAuth2Client = (): OAuth2Client => { + const isTauri: boolean = "__TAURI__" in window; + + const getPublicTokens = useGetPublicOAuthTokens(); + + const [settings] = useSettings(); + + return { + async getGrant(providerName, authUrl, tokenUrl, scopes) { + const authUrlResult = z.string().url().safeParse(authUrl); + + const authUrlOutput = parseZodOutput(authUrlResult); + + if (!authUrlOutput.ok) { + return authUrlOutput; + } + + if (settings.httpServerUrl === null) + return createBaseError({ + kind: "NoBackend", + message: "Backend server url is not set" + }); + + const publicTokensResult = await getPublicTokens().catch( + createResultFromUnknown + ); + + if (!publicTokensResult.ok) { + return publicTokensResult; + } + + const publicTokens = publicTokensResult.data; + + const providerDetails = findProviderToken(providerName, publicTokens); + + if (providerDetails === null) + return createBaseError({ + kind: "NoOAuthToken", + message: + "Could not find a oauth token on remote Dust-Mail server to authorize with email provider" + }); + + const providerToken = providerDetails[0]; + const providerId = providerDetails[1]; + + if (!isTauri) { + if (typeof window !== "undefined" && "open" in window) { + const url = new URL(authUrlOutput.data); + const redirectUri = new URL( + "/mail/oauth2/redirect", + settings.httpServerUrl + ); + + const state: OAuthState = { + provider: providerId, + application: isTauri ? "desktop" : "web" + }; + + // https://www.rfc-editor.org/rfc/rfc6749#section-1.1 + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", redirectUri.toString()); + url.searchParams.set("client_id", providerToken); + url.searchParams.set("scope", scopes.join(" ")); + url.searchParams.set("state", JSON.stringify(state)); + url.searchParams.set("access_type", "offline"); + + const oauthLoginWindow = window.open(url, "_blank", "popup"); + + if (oauthLoginWindow === null) + return createBaseError({ + kind: "UnsupportedEnvironment", + message: + "Your browser environment does not support intercommunication between windows" + }); + + oauthLoginWindow.addEventListener("message", console.log); + + return { ok: true as const, data: "" }; + } else { + return createBaseError({ + kind: "UnsupportedEnvironment", + message: + "Your browser environment does not support opening a new window" + }); + } + } + + return NotImplemented("oauth-grant-tauri"); + } + }; +}; + +export default useOAuth2Client; diff --git a/packages/pop3/src/lib.rs b/packages/pop3/src/lib.rs index b9b103d..dfc31e1 100644 --- a/packages/pop3/src/lib.rs +++ b/packages/pop3/src/lib.rs @@ -407,6 +407,24 @@ impl Client { Ok(()) } + pub fn auth>(&mut self, token: U) -> types::Result<()> { + self.is_correct_state(ClientState::Authentication)?; + + self.has_capability_else_err(vec![Capability::Sasl(vec![String::from("XOAUTH2")])])?; + + self.has_read_greeting()?; + + let socket = self.get_socket_mut()?; + + let command = format!("AUTH {}", token.as_ref()); + + socket.send_command(command.as_bytes(), false)?; + + self.state = ClientState::Transaction; + + Ok(()) + } + pub fn login(&mut self, user: &str, password: &str) -> types::Result<()> { self.is_correct_state(ClientState::Authentication)?; diff --git a/packages/sdk/src/client/incoming.rs b/packages/sdk/src/client/incoming.rs index a240145..4a64820 100644 --- a/packages/sdk/src/client/incoming.rs +++ b/packages/sdk/src/client/incoming.rs @@ -12,7 +12,7 @@ use crate::imap::{self, ImapClient}; use crate::pop::{self, PopClient}; use crate::types::{ - self, ConnectOptions, Error, ErrorKind, IncomingClientType, MailBox, Message, Preview, + Error, ErrorKind, IncomingClientType, MailBox, Message, OAuthCredentials, Preview, Result, }; enum IncomingClientTypeWithClient @@ -25,12 +25,17 @@ where Pop(PopClient), } -pub struct IncomingClient { +pub struct IncomingClient { client: IncomingClientTypeWithClient, } impl IncomingClient { - pub fn login(self, username: &str, password: &str) -> types::Result> { + /// Login to the specified mail server using a username and a password. + pub fn login>( + self, + username: T, + password: T, + ) -> Result> { match self.client { #[cfg(feature = "imap")] IncomingClientTypeWithClient::Imap(client) => { @@ -50,94 +55,141 @@ impl IncomingClient { )), } } + + /// Login to the specified mail servers using OAuth2 credentials, which consist of an access token and a usernames + pub fn oauth2_login( + self, + oauth_credentials: &OAuthCredentials, + ) -> Result> { + match self.client { + #[cfg(feature = "imap")] + IncomingClientTypeWithClient::Imap(client) => { + let session = client.oauth2_login(oauth_credentials)?; + + Ok(Box::new(session)) + } + // #[cfg(feature = "pop")] + // IncomingClientTypeWithClient::Pop(client) => { + // let session = client.login(username, password)?; + + // Ok(Box::new(session)) + // } + _ => Err(Error::new( + ErrorKind::NoClientAvailable, + "OAuth2 login is not supported by the specified incoming client type", + )), + } + } } -pub trait Session { +pub trait IncomingSession { /// Logout of the session, closing the connection with the server if applicable. - fn logout(&mut self) -> types::Result<()>; + fn logout(&mut self) -> Result<()>; /// Returns a list of all of the mailboxes that are on the server. - fn box_list(&mut self) -> types::Result<&Vec>; + fn box_list(&mut self) -> Result<&Vec>; /// Returns some basic information about a specified mailbox. - fn get(&mut self, box_id: &str) -> types::Result<&MailBox>; + fn get(&mut self, box_id: &str) -> Result<&MailBox>; /// Deletes a specified mailbox. - fn delete(&mut self, box_id: &str) -> types::Result<()>; + fn delete(&mut self, box_id: &str) -> Result<()>; /// Creates a new mailbox with a specified id. - fn create(&mut self, box_id: &str) -> types::Result<()>; + fn create(&mut self, box_id: &str) -> Result<()>; /// Renames a specified mailbox. - fn rename(&mut self, box_id: &str, new_name: &str) -> types::Result<()>; + fn rename(&mut self, box_id: &str, new_name: &str) -> Result<()>; /// Returns a list of a specified range of messages from a specified mailbox. - fn messages(&mut self, box_id: &str, start: u32, end: u32) -> types::Result>; + fn messages(&mut self, box_id: &str, start: u32, end: u32) -> Result>; /// Returns all of the relevant data for a specified message. - fn get_message(&mut self, box_id: &str, msg_id: &str) -> types::Result; + fn get_message(&mut self, box_id: &str, msg_id: &str) -> Result; +} + +/// A struct used to create a connection to an incoming mail server. +/// +/// Use the `new()` method to build a connection. +pub struct IncomingClientBuilder { + client_type: IncomingClientType, + server: Option, + port: Option, } -pub struct ClientConstructor; - -/// Check whether the login options exist if they are needed for a given client type. -fn check_options( - client_type: &IncomingClientType, - options: &Option, -) -> types::Result<()> { - match client_type { - #[cfg(feature = "imap")] - IncomingClientType::Imap => match options { - Some(_) => Ok(()), +impl IncomingClientBuilder { + /// Creates an incoming client builder given an incoming client type. + /// + /// This incoming client type will specify what kind of protocol is going to be used to connect to the later specified mail server. + pub fn new(client_type: &IncomingClientType) -> Self { + Self { + client_type: client_type.clone(), + port: None, + server: None, + } + } + + /// Set the port of the server to connect to. + /// + /// E.g `993` + pub fn set_port>(&mut self, port: S) -> &mut Self { + self.port = Some(port.into()); + + self + } + + /// Set the domain name of the server to connect to. + /// + /// E.g `example.com` + pub fn set_server>(&mut self, server: S) -> &mut Self { + self.server = Some(server.into()); + + self + } + + /// Internal function used to check if the domain and port have been set and return them if they are set. + /// + /// This function will error if either one is not set. + fn get_connect_config(&self) -> Result<(&str, &u16)> { + let port = match self.port.as_ref() { + Some(port) => port, None => { - return Err(types::Error::new( - types::ErrorKind::Unsupported, - "Imap support requires the login options to be specified", + return Err(Error::new( + ErrorKind::InvalidLoginConfig, + "Missing port from login config", )) } - }, - #[cfg(feature = "pop")] - IncomingClientType::Pop => match options { - Some(_) => Ok(()), + }; + + let server = match self.server.as_ref() { + Some(server) => server, None => { - return Err(types::Error::new( - types::ErrorKind::Unsupported, - "Pop support requires the login options to be specified", + return Err(Error::new( + ErrorKind::InvalidLoginConfig, + "Missing server from login config", )) } - }, + }; + + return Ok((server, port)); } -} -impl ClientConstructor { /// Creates a new client over a secure tcp connection. - pub fn new( - client_type: &IncomingClientType, - options: Option, - ) -> types::Result>> { - check_options(&client_type, &options)?; - - let client = match client_type { + pub fn build(&self) -> Result>> { + let client = match self.client_type { #[cfg(feature = "imap")] IncomingClientType::Imap => { - let options = match options { - Some(options) => options, - // Is unreachable as we have already checked the options for every client type - None => unreachable!(), - }; + let (server, port) = self.get_connect_config()?; - let client = imap::connect(options)?; + let client = imap::connect(server, port.clone())?; IncomingClientTypeWithClient::Imap(client) } #[cfg(feature = "pop")] IncomingClientType::Pop => { - let options = match options { - Some(options) => options, - None => unreachable!(), - }; + let (server, port) = self.get_connect_config()?; - let client = pop::connect(options)?; + let client = pop::connect(server, port.clone())?; IncomingClientTypeWithClient::Pop(client) } @@ -148,33 +200,22 @@ impl ClientConstructor { /// Creates a new client over a plain tcp connection. /// - /// # Do not use this in a production environment as it will send your credentials to the server without any encryption! - pub fn new_plain( - client_type: &IncomingClientType, - options: Option, - ) -> types::Result> { - check_options(&client_type, &options)?; - - let client = match client_type { + /// ### Do not use this in a production environment as it will send your credentials to the server without any encryption! + pub fn build_plain(&self) -> Result> { + let client = match self.client_type { #[cfg(feature = "imap")] IncomingClientType::Imap => { - let options = match options { - Some(options) => options, - None => unreachable!(), - }; + let (server, port) = self.get_connect_config()?; - let client = imap::connect_plain(options)?; + let client = imap::connect_plain(server, port.clone())?; IncomingClientTypeWithClient::Imap(client) } #[cfg(feature = "pop")] IncomingClientType::Pop => { - let options = match options { - Some(options) => options, - None => unreachable!(), - }; + let (server, port) = self.get_connect_config()?; - let client = pop::connect_plain(options)?; + let client = pop::connect_plain(server, port.clone())?; IncomingClientTypeWithClient::Pop(client) } @@ -183,51 +224,3 @@ impl ClientConstructor { Ok(IncomingClient { client }) } } - -#[cfg(test)] -mod tests { - use crate::types::ConnectOptions; - - use std::env; - - use dotenv::dotenv; - - use super::{IncomingClientType, Session}; - - fn create_session() -> Box { - dotenv().ok(); - - let username = env::var("IMAP_USERNAME").unwrap(); - let password = env::var("IMAP_PASSWORD").unwrap(); - - let server = env::var("IMAP_SERVER").unwrap(); - let port: u16 = 993; - - let options = ConnectOptions::new(server, &port); - - let client = - super::ClientConstructor::new(&IncomingClientType::Imap, Some(options)).unwrap(); - - let session = client.login(&username, &password).unwrap(); - - session - } - - #[test] - fn logout() { - let mut session = create_session(); - - session.logout().unwrap(); - } - - #[test] - fn box_list() { - let mut session = create_session(); - - let list = session.box_list().unwrap(); - - for mailbox in list { - println!("{}", mailbox.counts().unwrap().total()); - } - } -} diff --git a/packages/sdk/src/client/mod.rs b/packages/sdk/src/client/mod.rs index 28f2603..f8327bd 100644 --- a/packages/sdk/src/client/mod.rs +++ b/packages/sdk/src/client/mod.rs @@ -1 +1,4 @@ pub mod incoming; + +#[cfg(test)] +mod test; diff --git a/packages/sdk/src/client/test.rs b/packages/sdk/src/client/test.rs new file mode 100644 index 0000000..8c60026 --- /dev/null +++ b/packages/sdk/src/client/test.rs @@ -0,0 +1,45 @@ +use std::env; + +use dotenv::dotenv; + +use crate::types::IncomingClientType; + +use super::incoming::{IncomingClientBuilder, IncomingSession}; + +fn create_session() -> Box { + dotenv().ok(); + + let username = env::var("IMAP_USERNAME").unwrap(); + let password = env::var("IMAP_PASSWORD").unwrap(); + + let server = env::var("IMAP_SERVER").unwrap(); + let port: u16 = 993; + + let client = IncomingClientBuilder::new(&IncomingClientType::Imap) + .set_server(server) + .set_port(port) + .build() + .unwrap(); + + let session = client.login(&username, &password).unwrap(); + + session +} + +#[test] +fn logout() { + let mut session = create_session(); + + session.logout().unwrap(); +} + +#[test] +fn box_list() { + let mut session = create_session(); + + let list = session.box_list().unwrap(); + + for mailbox in list { + println!("{}", mailbox.counts().unwrap().total()); + } +} diff --git a/packages/sdk/src/detect/mod.rs b/packages/sdk/src/detect/mod.rs index e732b06..f548a2c 100644 --- a/packages/sdk/src/detect/mod.rs +++ b/packages/sdk/src/detect/mod.rs @@ -220,6 +220,7 @@ pub async fn from_email(email_address: &str) -> Result { config = Some(Config::new( ConfigType::new_multiserver(incoming_configs, outgoing_configs), domain.as_str(), + None, Some(domain.to_string()), )); } diff --git a/packages/sdk/src/detect/parse.rs b/packages/sdk/src/detect/parse.rs index 90f2fb1..0f31f6d 100644 --- a/packages/sdk/src/detect/parse.rs +++ b/packages/sdk/src/detect/parse.rs @@ -3,13 +3,16 @@ use autoconfig::{ self, types::config::{ AuthenticationType as AutoConfigAuthenticationType, Config as AutoConfig, - SecurityType as AutoConfigSecurityType, Server, ServerType as AutoConfigServerType, + OAuth2Config as AutoConfigOAuth2Config, SecurityType as AutoConfigSecurityType, Server, + ServerType as AutoConfigServerType, }, }; use crate::types::{ConnectionSecurity, Result}; -use super::{AuthenticationType, Config, ConfigType, ServerConfig, ServerConfigType}; +use super::{ + types::OAuth2Config, AuthenticationType, Config, ConfigType, ServerConfig, ServerConfigType, +}; #[cfg(feature = "autoconfig")] pub struct AutoConfigParser; @@ -20,7 +23,7 @@ impl AutoConfigParser { { let domain: String = server.hostname()?.into(); - let port: u16 = *server.port()?; + let port: u16 = server.port().cloned()?; let security: ConnectionSecurity = match server.security_type() { Some(security) => match security { @@ -60,8 +63,17 @@ impl AutoConfigParser { } } + fn parse_oauth2_config(config: &AutoConfigOAuth2Config) -> OAuth2Config { + OAuth2Config::new( + config.token_url().into(), + config.auth_url().into(), + config.scope(), + ) + } + pub fn parse(autoconfig: AutoConfig) -> Result { let provider: String = autoconfig.email_provider().id().into(); + let display_name: Option = autoconfig .email_provider() .display_name() @@ -83,7 +95,9 @@ impl AutoConfigParser { let config_type = ConfigType::MultiServer { incoming, outgoing }; - let config = Config::new(config_type, provider, display_name); + let oauth2_config = autoconfig.oauth2().map(Self::parse_oauth2_config); + + let config = Config::new(config_type, provider, oauth2_config, display_name); Ok(config) } diff --git a/packages/sdk/src/detect/types.rs b/packages/sdk/src/detect/types.rs index 6b83ede..fe45be7 100644 --- a/packages/sdk/src/detect/types.rs +++ b/packages/sdk/src/detect/types.rs @@ -70,6 +70,37 @@ pub enum AuthenticationType { Unknown, } +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct OAuth2Config { + token_url: String, + oauth_url: String, + scopes: Vec, +} + +impl OAuth2Config { + pub fn new>(token_url: S, oauth_url: S, scopes: Vec) -> Self { + let scopes = scopes.into_iter().map(|scope| scope.into()).collect(); + + Self { + oauth_url: oauth_url.into(), + token_url: token_url.into(), + scopes, + } + } + pub fn oauth_url(&self) -> &str { + &self.oauth_url + } + + pub fn token_url(&self) -> &str { + &self.token_url + } + + pub fn scopes(&self) -> &Vec { + &self.scopes + } +} + #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub enum ConfigType { @@ -90,6 +121,7 @@ impl ConfigType { pub struct Config { r#type: ConfigType, provider: String, + oauth2: Option, display_name: Option, } @@ -97,15 +129,21 @@ impl Config { pub fn new>( r#type: ConfigType, provider: S, + oauth2_config: Option, display_name: Option, ) -> Self { Self { display_name, + oauth2: oauth2_config, provider: provider.into(), r#type, } } + pub fn oauth2(&self) -> &Option { + &self.oauth2 + } + /// The kind of config pub fn config_type(&self) -> &ConfigType { &self.r#type diff --git a/packages/sdk/src/imap/mod.rs b/packages/sdk/src/imap/mod.rs index 4c45bc3..7261c57 100644 --- a/packages/sdk/src/imap/mod.rs +++ b/packages/sdk/src/imap/mod.rs @@ -9,16 +9,16 @@ use std::time::{Duration, Instant}; use imap::types::{Fetch as ImapFetch, Mailbox as ImapMailbox}; use native_tls::TlsStream; -use crate::client::incoming::Session; +use crate::client::incoming::IncomingSession; use crate::parse::map_imap_error; use crate::tls::create_tls_connector; use crate::types::{ - ConnectOptions, // Counts, Error, ErrorKind, MailBox, Message, + OAuthCredentials, Preview, Result, }; @@ -44,24 +44,25 @@ pub struct ImapSession { selected_box: Option<(String, ImapMailbox)>, } -pub fn connect(options: ConnectOptions) -> Result>> { +pub fn connect, P: Into>( + server: S, + port: P, +) -> Result>> { let tls = create_tls_connector()?; - let domain = options.server(); - let port = *options.port(); - - let client = imap::connect((domain, port), domain, &tls).map_err(map_imap_error)?; + let client = imap::connect((server.as_ref(), port.into()), server.as_ref(), &tls) + .map_err(map_imap_error)?; let imap_client = ImapClient { client }; Ok(imap_client) } -pub fn connect_plain(options: ConnectOptions) -> Result> { - let domain = options.server(); - let port = *options.port(); - - let stream = TcpStream::connect((domain, port)) +pub fn connect_plain, P: Into>( + server: S, + port: P, +) -> Result> { + let stream = TcpStream::connect((server.as_ref(), port.into())) .map_err(|e| Error::new(ErrorKind::Connect, e.to_string()))?; let client = imap::Client::new(stream); @@ -70,7 +71,7 @@ pub fn connect_plain(options: ConnectOptions) -> Result> { } impl ImapClient { - pub fn login(self, username: &str, password: &str) -> Result> { + pub fn login>(self, username: T, password: T) -> Result> { let session = self .client .login(username, password) @@ -80,7 +81,22 @@ impl ImapClient { session, box_list: Vec::new(), box_list_last_refresh: None, - // retrieved_message_counts: false, + selected_box: None, + }; + + Ok(imap_session) + } + + pub fn oauth2_login(self, login: &OAuthCredentials) -> Result> { + let session = self + .client + .authenticate("XOAUTH2", login) + .map_err(|(err, _)| map_imap_error(err))?; + + let imap_session = ImapSession { + session, + box_list: Vec::new(), + box_list_last_refresh: None, selected_box: None, }; @@ -165,7 +181,7 @@ impl ImapSession { } } -impl Session for ImapSession { +impl IncomingSession for ImapSession { fn logout(&mut self) -> Result<()> { let session = self.get_session_mut(); @@ -391,9 +407,9 @@ mod tests { use native_tls::TlsStream; - use super::{ConnectOptions, ImapSession}; + use super::ImapSession; - use crate::client::incoming::Session; + use crate::client::incoming::IncomingSession; use dotenv::dotenv; @@ -408,9 +424,7 @@ mod tests { let server = env::var("IMAP_SERVER").unwrap(); let port: u16 = 993; - let options = ConnectOptions::new(server, &port); - - let client = super::connect(options).unwrap(); + let client = super::connect(server, port).unwrap(); let session = client.login(&username, &password).unwrap(); diff --git a/packages/sdk/src/lib.rs b/packages/sdk/src/lib.rs index 5522eab..837e7ec 100644 --- a/packages/sdk/src/lib.rs +++ b/packages/sdk/src/lib.rs @@ -16,6 +16,4 @@ pub mod types; pub mod session; -pub use client::incoming::{ - ClientConstructor as IncomingClientConstructor, Session as IncomingSession, -}; +pub use client::incoming::{IncomingClientBuilder, IncomingSession}; diff --git a/packages/sdk/src/pop/mod.rs b/packages/sdk/src/pop/mod.rs index fc9e1c9..37a3476 100644 --- a/packages/sdk/src/pop/mod.rs +++ b/packages/sdk/src/pop/mod.rs @@ -6,10 +6,10 @@ use native_tls::TlsStream; use pop3::types::{Left, Right}; use crate::{ - client::incoming::Session, + client::incoming::IncomingSession, parse::{map_pop_error, parse_headers, parse_rfc822}, tls::create_tls_connector, - types::{ConnectOptions, Counts, Error, ErrorKind, Flag, MailBox, Message, Preview, Result}, + types::{Counts, Error, ErrorKind, Flag, MailBox, Message, Preview, Result}, }; use parse::parse_address; @@ -28,31 +28,35 @@ pub struct PopSession { unique_id_map: HashMap, } -pub fn connect(options: ConnectOptions) -> Result>> { +pub fn connect, P: Into>( + server: S, + port: P, +) -> Result>> { let tls = create_tls_connector()?; - let server = options.server(); - let port = *options.port(); - - let session = pop3::connect((server, port), server, &tls, None).map_err(map_pop_error)?; + let session = pop3::connect((server.as_ref(), port.into()), server.as_ref(), &tls, None) + .map_err(map_pop_error)?; Ok(PopClient { session }) } -pub fn connect_plain(options: ConnectOptions) -> Result> { - let server = options.server(); - let port = *options.port(); - - let session = pop3::connect_plain((server, port), None).map_err(map_pop_error)?; +pub fn connect_plain, P: Into>( + server: S, + port: P, +) -> Result> { + let session = + pop3::connect_plain((server.as_ref(), port.into()), None).map_err(map_pop_error)?; Ok(PopClient { session }) } impl PopClient { - pub fn login(self, username: &str, password: &str) -> Result> { + pub fn login>(self, username: T, password: T) -> Result> { let mut session = self.session; - session.login(username, password).map_err(map_pop_error)?; + session + .login(username.as_ref(), password.as_ref()) + .map_err(map_pop_error)?; // session.capabilities() @@ -123,7 +127,7 @@ impl PopSession { } } -impl Session for PopSession { +impl IncomingSession for PopSession { fn logout(&mut self) -> Result<()> { let session = self.get_session_mut(); @@ -276,7 +280,7 @@ mod test { use super::PopSession; - use crate::{client::incoming::Session, types::ConnectOptions}; + use crate::client::incoming::IncomingSession; use dotenv::dotenv; use native_tls::TlsStream; @@ -291,9 +295,7 @@ mod test { let server = env::var("POP_SERVER").unwrap(); let port: u16 = 995; - let options = ConnectOptions::new(&server, &port); - - let client = super::connect(options).unwrap(); + let client = super::connect(server, port).unwrap(); let session = client.login(&username, &password).unwrap(); diff --git a/packages/sdk/src/session/incoming.rs b/packages/sdk/src/session/incoming.rs index 3d03f2f..071273d 100644 --- a/packages/sdk/src/session/incoming.rs +++ b/packages/sdk/src/session/incoming.rs @@ -1,26 +1,47 @@ +use std::io::{Read, Write}; + use crate::{ - client::incoming::{ClientConstructor as IncomingClientConstructor, Session}, - types::{ConnectOptions, ConnectionSecurity, LoginOptions, Result, IncomingClientType}, + client::incoming::{IncomingClient, IncomingClientBuilder, IncomingSession}, + types::{ConnectionSecurity, IncomingClientType, Result}, }; -pub fn create_session( +use super::login::{LoginOptions, LoginType}; + +fn create_session_from_client( + client: IncomingClient, + login_type: &LoginType, +) -> Result> { + match login_type { + LoginType::PasswordBased(password_creds) => { + client.login(password_creds.username(), password_creds.password()) + } + LoginType::OAuthBased(oauth_creds) => client.oauth2_login(oauth_creds), + } +} + +/// Given some login options and a client type, create an incoming session. +/// +/// This will automatically connect and login to the mail server specified in the login options using the credentials specified in the login options. +pub fn create_incoming_session( options: &LoginOptions, client_type: &IncomingClientType, -) -> Result> { - let client_options = ConnectOptions::new(options.domain(), options.port()); +) -> Result> { + let mut builder = IncomingClientBuilder::new(client_type); + + builder + .set_server(options.domain()) + .set_port(options.port().clone()); match options.security() { ConnectionSecurity::Tls => { - let client = IncomingClientConstructor::new(client_type, Some(client_options))?; + let client = builder.build()?; - client - .login(options.username(), options.password()) + create_session_from_client(client, options.login_type()) } ConnectionSecurity::Plain => { - let client = IncomingClientConstructor::new_plain(client_type, Some(client_options))?; + let client = builder.build_plain()?; - client - .login(options.username(), options.password()) + create_session_from_client(client, options.login_type()) } _ => { todo!() diff --git a/packages/sdk/src/session/login.rs b/packages/sdk/src/session/login.rs new file mode 100644 index 0000000..2812e22 --- /dev/null +++ b/packages/sdk/src/session/login.rs @@ -0,0 +1,197 @@ +use serde::{Deserialize, Serialize}; + +use crate::types::{ConnectionSecurity, IncomingClientType, OAuthCredentials}; + +#[derive(Deserialize, Serialize, Clone)] +pub struct PasswordCredentials { + username: String, + password: String, +} + +impl PasswordCredentials { + pub fn password(&self) -> &str { + &self.password + } + + pub fn username(&self) -> &str { + &self.username + } + + pub fn new>(username: S, password: S) -> Self { + Self { + password: password.into(), + username: username.into(), + } + } +} + +#[derive(Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub enum LoginType { + PasswordBased(PasswordCredentials), + OAuthBased(OAuthCredentials), +} + +#[derive(Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +/// A struct that specifies all of the need options and credentials to login to a mail server. +pub struct LoginOptions { + login_type: LoginType, + domain: String, + port: u16, + security: ConnectionSecurity, +} + +impl LoginOptions { + pub fn domain(&self) -> &str { + &self.domain + } + + pub fn set_domain>(&mut self, domain: S) { + self.domain = domain.into(); + } + + pub fn port(&self) -> &u16 { + &self.port + } + + pub fn set_port>(&mut self, port: u16) { + self.port = port.into(); + } + + pub fn security(&self) -> &ConnectionSecurity { + &self.security + } + + pub fn login_type(&self) -> &LoginType { + &self.login_type + } +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +/// The bigger brother of `LoginOptions`. +/// +/// Contains all of the information needed to login to an incoming and outgoing mail server and specifies what protocol is need to do so. +pub struct FullLoginOptions { + incoming: LoginOptions, + incoming_type: IncomingClientType, +} + +impl FullLoginOptions { + pub fn incoming_options(&self) -> &LoginOptions { + &self.incoming + } + + pub fn incoming_type(&self) -> &IncomingClientType { + &self.incoming_type + } +} + +pub struct FullLoginOptionsBuilder { + incoming_type: IncomingClientType, + domain: Option, + port: Option, + security: Option, + username: Option, + password: Option, + access_token: Option, +} + +impl FullLoginOptionsBuilder { + pub fn new(incoming_type: &IncomingClientType) -> Self { + Self { + incoming_type: incoming_type.clone(), + access_token: None, + domain: None, + password: None, + port: None, + security: None, + username: None, + } + } + + pub fn domain>(&mut self, domain: S) -> &mut Self { + self.domain = Some(domain.into()); + + self + } + + pub fn port(&mut self, port: u16) -> &mut Self { + self.port = Some(port); + + self + } + + pub fn security(&mut self, security: ConnectionSecurity) -> &mut Self { + self.security = Some(security); + + self + } + + pub fn username>(&mut self, username: S) -> &mut Self { + self.username = Some(username.into()); + + self + } + + pub fn password>(&mut self, password: S) -> &mut Self { + self.password = Some(password.into()); + + self + } + + pub fn access_token>(&mut self, access_token: S) -> &mut Self { + self.access_token = Some(access_token.into()); + + self + } + + pub fn build(self) -> Option { + let domain = self.domain?; + let port = self.port?; + let security = self.security?; + let username = self.username?; + + let password = self.password; + let access_token = self.access_token; + + let mut login_type: Option = None; + + if password.is_some() { + let password_creds = PasswordCredentials::new(username, password.unwrap()); + + login_type = Some(LoginType::PasswordBased(password_creds)); + } else if access_token.is_some() { + let oauth_creds = OAuthCredentials::new(username, access_token.unwrap()); + + login_type = Some(LoginType::OAuthBased(oauth_creds)); + } + + let login_options = LoginOptions { + login_type: login_type?, + domain, + port, + security, + }; + + Some(FullLoginOptions { + incoming: login_options, + incoming_type: self.incoming_type, + }) + } +} + +impl Into for FullLoginOptions { + /// Creates an identifiable string from the credentials, excluding the password. + fn into(self) -> String { + let incoming = self.incoming_options(); + + let username = match incoming.login_type() { + LoginType::OAuthBased(oauth_creds) => oauth_creds.username(), + LoginType::PasswordBased(password_creds) => password_creds.username(), + }; + + format!("{}@{}:{}", username, incoming.domain(), incoming.port()) + } +} diff --git a/packages/sdk/src/session/mod.rs b/packages/sdk/src/session/mod.rs index 3a4bdd8..3f9d456 100644 --- a/packages/sdk/src/session/mod.rs +++ b/packages/sdk/src/session/mod.rs @@ -1,14 +1,15 @@ mod incoming; +mod login; use std::sync::{Arc, Mutex}; -use serde::{Deserialize, Serialize}; use tokio::task::spawn_blocking; -use crate::{ - types::{IncomingClientType, LoginOptions, Result}, - IncomingSession, -}; +use crate::{types::Result, IncomingSession}; + +use self::incoming::create_incoming_session; + +pub use self::login::{FullLoginOptions, FullLoginOptionsBuilder}; pub type ThreadSafeIncomingSession = Arc>>; @@ -28,44 +29,7 @@ impl MailSessions { } } -#[derive(Serialize, Deserialize, Clone)] -pub struct Credentials { - incoming: LoginOptions, - incoming_type: IncomingClientType, -} - -impl Credentials { - pub fn incoming_options(&self) -> &LoginOptions { - &self.incoming - } - - pub fn incoming_type(&self) -> &IncomingClientType { - &self.incoming_type - } - - pub fn new(incoming: (LoginOptions, IncomingClientType)) -> Self { - Self { - incoming: incoming.0, - incoming_type: incoming.1, - } - } -} - -impl Into for Credentials { - /// Creates an identifiable string from the credentials, excluding the password. - fn into(self) -> String { - let incoming = self.incoming_options(); - - format!( - "{}@{}:{}", - incoming.username(), - incoming.domain(), - incoming.port() - ) - } -} - -pub async fn create_sessions(credentials: &Credentials) -> Result { +pub async fn create_sessions(credentials: &FullLoginOptions) -> Result { let incoming_credentials = ( credentials.incoming_options().clone(), credentials.incoming_type().clone(), @@ -73,7 +37,7 @@ pub async fn create_sessions(credentials: &Credentials) -> Result // Try to get a session for all of the given login options let incoming_login_thread = spawn_blocking(move || { - incoming::create_session(&incoming_credentials.0, &incoming_credentials.1) + create_incoming_session(&incoming_credentials.0, &incoming_credentials.1) }); // let outgoing_login_thread; diff --git a/packages/sdk/src/types/connection.rs b/packages/sdk/src/types/connection.rs new file mode 100644 index 0000000..51726f7 --- /dev/null +++ b/packages/sdk/src/types/connection.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum ConnectionSecurity { + Tls, + StartTls, + Plain, +} diff --git a/packages/sdk/src/types/login.rs b/packages/sdk/src/types/login.rs deleted file mode 100644 index 5f59960..0000000 --- a/packages/sdk/src/types/login.rs +++ /dev/null @@ -1,62 +0,0 @@ -use serde::{Deserialize, Serialize}; - -pub struct ConnectOptions { - server: String, - port: u16, -} - -impl ConnectOptions { - pub fn new>(server: S, port: &u16) -> Self { - Self { - server: server.into(), - port: *port, - } - } - - pub fn server(&self) -> &str { - &self.server - } - - pub fn port(&self) -> &u16 { - &self.port - } -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub enum ConnectionSecurity { - Tls, - StartTls, - Plain, -} - -#[derive(Deserialize, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct LoginOptions { - username: String, - password: String, - domain: String, - port: u16, - security: ConnectionSecurity, -} - -impl LoginOptions { - pub fn domain(&self) -> &str { - &self.domain - } - - pub fn port(&self) -> &u16 { - &self.port - } - - pub fn security(&self) -> &ConnectionSecurity { - &self.security - } - - pub fn username(&self) -> &str { - &self.username - } - - pub fn password(&self) -> &str { - &self.password - } -} diff --git a/packages/sdk/src/types/mod.rs b/packages/sdk/src/types/mod.rs index 26dfcda..1c448d2 100644 --- a/packages/sdk/src/types/mod.rs +++ b/packages/sdk/src/types/mod.rs @@ -1,18 +1,20 @@ mod client; +mod connection; mod error; mod flags; -mod login; mod mailbox; mod message; +mod oauth2; use std::{collections::HashMap, result}; pub use client::*; +pub use connection::ConnectionSecurity; pub use error::{Error, ErrorKind}; pub use flags::Flag; -pub use login::{ConnectOptions, ConnectionSecurity, LoginOptions}; pub use mailbox::{Counts, MailBox}; pub use message::{Address, Content, Message, Preview}; +pub use oauth2::OAuthCredentials; pub type Result = result::Result; diff --git a/packages/sdk/src/types/oauth2.rs b/packages/sdk/src/types/oauth2.rs new file mode 100644 index 0000000..1e2f887 --- /dev/null +++ b/packages/sdk/src/types/oauth2.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct OAuthCredentials { + username: String, + access_token: String, +} + +impl OAuthCredentials { + pub fn new>(username: S, access_token: S) -> Self { + Self { + access_token: access_token.into(), + username: username.into(), + } + } + + pub fn username(&self) -> &str { + &self.username + } + + pub fn access_token(&self) -> &str { + &self.access_token + } +} + +#[cfg(feature = "imap")] +impl imap::Authenticator for OAuthCredentials { + type Response = String; + + fn process(&self, _: &[u8]) -> Self::Response { + format!( + "user={}\x01auth=Bearer {}\x01\x01", + self.username, self.access_token + ) + } +} diff --git a/packages/structures/package.json b/packages/structures/package.json index 8b5cf64..ddf3720 100644 --- a/packages/structures/package.json +++ b/packages/structures/package.json @@ -18,6 +18,6 @@ "typescript": "^4.7.4" }, "dependencies": { - "zod": "^3.20.2" + "zod": "^3.21.4" } } diff --git a/packages/structures/src/api/index.ts b/packages/structures/src/api/index.ts index 53c9a27..e9ca7c8 100644 --- a/packages/structures/src/api/index.ts +++ b/packages/structures/src/api/index.ts @@ -22,3 +22,4 @@ export const ApiResponseModel = z.union([ export type ApiResponse = ApiOkResponse | AppError; export * from "./settings"; +export * from "./oauth"; diff --git a/packages/structures/src/api/oauth.ts b/packages/structures/src/api/oauth.ts new file mode 100644 index 0000000..9619cb3 --- /dev/null +++ b/packages/structures/src/api/oauth.ts @@ -0,0 +1,7 @@ +import z from "zod"; + +export const OAuthStateModel = z.object({ + provider: z.string(), + application: z.enum(["desktop", "web"]) +}); +export type OAuthState = z.infer; diff --git a/packages/structures/src/api/settings.ts b/packages/structures/src/api/settings.ts index 7a14bcc..38c367f 100644 --- a/packages/structures/src/api/settings.ts +++ b/packages/structures/src/api/settings.ts @@ -2,6 +2,6 @@ import z from "zod"; export const ApiSettingsModel = z.object({ authorization: z.boolean(), - authorization_type: z.enum(["password", "user"]) + authorization_type: z.enum(["password", "user"]).nullable() }); export type ApiSettings = z.infer; diff --git a/packages/structures/src/config.ts b/packages/structures/src/config.ts index ef29949..3ff76ba 100644 --- a/packages/structures/src/config.ts +++ b/packages/structures/src/config.ts @@ -44,9 +44,17 @@ export const ConfigTypeModel = z.record( ); export type ConfigType = z.infer; +export const OAuth2ConfigModel = z.object({ + tokenUrl: z.string().url(), + oauthUrl: z.string().url(), + scopes: z.string().array() +}); +export type OAuth2Config = z.infer; + export const MailConfigModel = z.object({ type: ConfigTypeModel, provider: z.string(), + oauth2: OAuth2ConfigModel.nullable(), displayName: z.string() }); export type MailConfig = z.infer; diff --git a/packages/structures/src/login.ts b/packages/structures/src/login.ts index 72b3646..3676232 100644 --- a/packages/structures/src/login.ts +++ b/packages/structures/src/login.ts @@ -38,9 +38,35 @@ export const ServerTypeModel = z.union([ ]); export type ServerType = z.infer; -export const LoginOptionsModel = z.object({ +export const PasswordBasedLoginLiteral = "passwordBased" as const; +export const PasswordBasedLoginLiteralModel = z.literal( + PasswordBasedLoginLiteral +); + +export const OAuthBasedLoginLiteral = "oAuthBased" as const; +export const OAuthBasedLoginLiteralModel = z.literal(OAuthBasedLoginLiteral); + +export const PasswordCredentialsModel = z.object({ + username: z.string(), + password: z.string() +}); +export type PasswordCredentials = z.infer; + +export const OAuthCredentialsModel = z.object({ username: z.string(), - password: z.string(), + accessToken: z.string() +}); +export type OAuthCredentials = z.infer; + +export const LoginTypeModel = z.object({ + [PasswordBasedLoginLiteral]: PasswordCredentialsModel.optional(), + [OAuthBasedLoginLiteral]: OAuthCredentialsModel.optional() +}); + +export type LoginType = z.infer; + +export const LoginOptionsModel = z.object({ + loginType: LoginTypeModel, domain: z.string(), port: z.number(), security: ConnectionSecurityModel @@ -49,6 +75,6 @@ export type LoginOptions = z.infer; export const CredentialsModel = z.object({ incoming: LoginOptionsModel, - incoming_type: IncomingMailServerTypeModel + incomingType: IncomingMailServerTypeModel }); export type Credentials = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bcc5261..43836c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,9 +189,9 @@ importers: rollup-plugin-ts: ^3.0.2 rollup-plugin-typescript2: ^0.31.1 typescript: ^4.7.4 - zod: ^3.20.2 + zod: ^3.21.4 dependencies: - zod: 3.20.2 + zod: 3.21.4 devDependencies: '@dust-mail/tsconfig': link:../tsconfig '@types/node': 18.7.13 @@ -10117,6 +10117,10 @@ packages: resolution: {integrity: sha512-1MzNQdAvO+54H+EaK5YpyEy0T+Ejo/7YLHS93G3RnYWh5gaotGHwGeN/ZO687qEDU2y4CdStQYXVHIgrUl5UVQ==} dev: false + /zod/3.21.4: + resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} + dev: false + /zustand/4.0.0_react@18.2.0: resolution: {integrity: sha512-OrsfQTnRXF1LZ9/vR/IqN9ws5EXUhb149xmPjErZnUrkgxS/gAHGy2dPNIVkVvoxrVe1sIydn4JjF0dYHmGeeQ==} engines: {node: '>=12.7.0'}