feat(sdk): started work on oauth support
continuous-integration/drone/push Build is failing Details

main
Guus van Meerveld 7 months ago
parent 8cf965ef81
commit cc322a8182
Signed by: Guusvanmeerveld
GPG Key ID: 2BA7D7912771966E

7
Cargo.lock generated

@ -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",

@ -6,7 +6,6 @@
<div align="center">
<img src="https://ci.guusvanmeerveld.dev/api/badges/Guusvanmeerveld/Dust-Mail/status.svg" alt="Deploy Recent commit" />
<img alt="Mail Discover Downloads" src="https://img.shields.io/npm/dw/mail-discover?label=mail-discover" />
<img alt="Docker Image Size (tag)" src="https://img.shields.io/docker/image-size/guusvanmeerveld/dust-mail/latest?label=Standalone%20image%20size" />
<a href="https://discord.gg/ybBaCaxfdt"><img alt="Discord" src="https://img.shields.io/discord/1000421125844639797"></a>
<img alt="MIT license" src="https://img.shields.io/github/license/Guusvanmeerveld/Dust-Mail" />

@ -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"

@ -12,25 +12,17 @@ use crate::{
};
pub struct User {
token: String,
mail_sessions: Arc<UserSession>,
}
impl User {
pub fn new<S: Into<String>>(token: S, mail_sessions: Arc<UserSession>) -> Self {
User {
mail_sessions,
token: token.into(),
}
pub fn new(mail_sessions: Arc<UserSession>) -> Self {
User { mail_sessions }
}
pub fn mail_sessions(&self) -> Arc<UserSession> {
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(

@ -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<HttpConnector>,
client_tls: Client<HttpsConnector<HttpConnector>>,
}
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<Body>) -> Result<Vec<u8>> {
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))
}
}

@ -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<ErrResponse>) {
)
}
#[catch(401)]
fn unauthorized() -> (Status, Json<ErrResponse>) {
ErrResponse::new(ErrorKind::Unauthorized, "Unauthorized")
}
#[catch(500)]
fn internal_error() -> (Status, Json<ErrResponse>) {
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
],
)
}

@ -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<String>,
}
impl AccessTokenResponse {
pub fn access_token(&self) -> &str {
&self.access_token
}
}
pub async fn get_access_token<S: AsRef<str>>(
http_client: &HttpClient,
token_url: S,
code: S,
redirect_uri: S,
client_id: S,
client_secret: &Option<String>,
) -> Result<AccessTokenResponse> {
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)
}

@ -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 = "<credentials>")]
pub async fn login(
credentials: Json<Credentials>,
credentials: Json<FullLoginOptions>,
user: User,
_rate_limiter: RateLimiter,
config: &State<Config>,

@ -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::*;

@ -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;

@ -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?<code>&<state>&<scope>&<error>")]
pub async fn handle_redirect(
code: Option<String>,
state: Json<OAuthState>,
scope: Option<String>,
error: Option<String>,
config: &State<Config>,
http_client: &State<HttpClient>,
) -> ResponseResult<String> {
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",
))
}
}

@ -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<Config>) -> ResponseResult<HashMap<String, String>> {
let public_tokens: HashMap<String, String> = config
.oauth2()
.providers()
.iter()
.map(|(key, value)| return (key.to_string(), value.public_token().to_string()))
.collect();
Ok(OkResponse::new(public_tokens))
}

@ -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<Authorization>,
}
@ -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(),
}

@ -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<String, Provider>,
}
impl OAuth2 {
pub fn providers(&self) -> &HashMap<String, Provider> {
&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<String>,
token_url: String,
}
impl Provider {
pub fn public_token(&self) -> &str {
&self.public_token
}
pub fn secret_token(&self) -> &Option<String> {
&self.secret_token
}
pub fn token_url(&self) -> &str {
&self.token_url
}
}

@ -7,6 +7,8 @@ use sdk::types::Error as SdkError;
#[serde(crate = "rocket::serde")]
pub enum ErrorKind {
SdkError(SdkError),
CreateHttpRequest,
BadConfig,
Unauthorized,
BadRequest,
TooManyRequests,

@ -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,

@ -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<Config> {
#[tauri::command(async)]
pub async fn login(
credentials: Credentials,
credentials: FullLoginOptions,
session_handler: State<'_, Sessions>,
) -> Result<String> {
// 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<Vec<Ma
let fetch_box_list = spawn_blocking(move || {
let mut session_lock = session.lock().unwrap();
let list = session_lock
.box_list()
.map(|box_list| box_list.clone())?;
let list = session_lock.box_list().map(|box_list| box_list.clone())?;
Ok(list)
});
@ -57,9 +58,7 @@ pub async fn get(token: String, box_id: String, sessions: State<'_, Sessions>) -
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)
});

@ -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<Credentials> {
pub fn get_credentials(token: &str) -> Result<FullLoginOptions> {
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<Credentials> {
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<Credentials> {
Ok(login_options)
}
pub fn generate_token(credentials: &Credentials) -> Result<String> {
pub fn generate_token(credentials: &FullLoginOptions) -> Result<String> {
// Serialize the given options to json
let options_json = parse::to_json(credentials)?;

@ -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<void> => {
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 = () => {
<Grid container spacing={2}>
<ServerConfigColumn
type="incoming"
port={incoming.port}
security={incoming.security}
server={incoming.domain}
username={incoming.username}
password={incoming.password}
port={incomingConfig.port}
security={incomingConfig.security}
server={incomingConfig.domain}
username={incomingConfig.username}
password={incomingConfig.password}
selectedMailServerType={selectedMailServerTypes.incoming}
/>
<ServerConfigColumn
type="outgoing"
port={outgoing.port}
security={outgoing.security}
server={outgoing.domain}
username={outgoing.username}
password={outgoing.password}
port={outgoingConfig.port}
security={outgoingConfig.security}
server={outgoingConfig.domain}
username={outgoingConfig.username}
password={outgoingConfig.password}
selectedMailServerType={selectedMailServerTypes.outgoing}
/>
</Grid>
@ -433,6 +459,7 @@ const LoginForm: FC<{
const [error, setError] = useState<string>();
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,

@ -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[];
}

@ -0,0 +1,10 @@
import { Result } from "./result";
export default interface OAuth2Client {
getGrant: (
providerName: string,
grantUrl: string,
tokenUrl: string,
scopes: string[]
) => Promise<Result<string>>;
}

@ -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;

@ -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"
});

@ -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<Result<void>>) => {
@ -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

@ -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;

@ -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<LoginOptions, "loginType">;
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<MailServerType, number> = {
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 = {

@ -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<Record<string, string>>
>) => {
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>
): [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;

@ -407,6 +407,24 @@ impl<S: Read + Write> Client<S> {
Ok(())
}
pub fn auth<U: AsRef<str>>(&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)?;

@ -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<S>
@ -25,12 +25,17 @@ where
Pop(PopClient<S>),
}
pub struct IncomingClient<S: Read + Write> {
pub struct IncomingClient<S: Read + Write + Send> {
client: IncomingClientTypeWithClient<S>,
}
impl<S: Read + Write + 'static + Send> IncomingClient<S> {
pub fn login(self, username: &str, password: &str) -> types::Result<Box<dyn Session + Send>> {
/// Login to the specified mail server using a username and a password.
pub fn login<T: AsRef<str>>(
self,
username: T,
password: T,
) -> Result<Box<dyn IncomingSession + Send>> {
match self.client {
#[cfg(feature = "imap")]
IncomingClientTypeWithClient::Imap(client) => {
@ -50,94 +55,141 @@ impl<S: Read + Write + 'static + Send> IncomingClient<S> {
)),
}
}
/// 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<Box<dyn IncomingSession + Send>> {
match self.client {
#[cfg(feature = "imap")]
IncomingClientTypeWithClient::Imap(client) => {
let session = client.oauth2_login(oauth_credentials)?;