feat(web): now supports rust backend api server
continuous-integration/drone/push Build is passing Details

main
Guus van Meerveld 7 months ago
parent 284321a240
commit f450a1f735
Signed by: Guusvanmeerveld
GPG Key ID: 2BA7D7912771966E

@ -26,6 +26,8 @@
!/apps/web/package.json
!/apps/web/src-tauri
!/target
!/apps/server/src
!/apps/server/Cargo.toml

@ -1,10 +0,0 @@
version: "3.3"
services:
api:
container_name: dust-mail-api
image: guusvanmeerveld/dust-mail-server
ports:
- 8080:8080
volumes:
- ./config/config.toml:/home/dust-mail/.config/dust-mail-server/config.toml

@ -75,6 +75,8 @@ fn rocket() -> _ {
"/",
routes![
routes::auto_detect_config_handler,
routes::settings_handler,
routes::version_handler,
routes::login_handler,
routes::logout_handler
],

@ -3,12 +3,14 @@ use crate::{
types::{ErrResponse, Error, OkResponse, ResponseResult},
};
#[post("/logout/<session_token>")]
#[post("/logout?<session_token>")]
pub fn logout(
session_token: String,
user: User,
_rate_limiter: RateLimiter,
) -> ResponseResult<String> {
user.mail_sessions().remove(session_token.clone());
let incoming_session = user
.mail_sessions()
.get_incoming(&session_token)
@ -20,7 +22,5 @@ pub fn logout(
.logout()
.map_err(|err| ErrResponse::from(Error::from(err)).into())?;
user.mail_sessions().remove(session_token);
Ok(OkResponse::new(String::from("Logout successfull")))
}

@ -2,9 +2,13 @@ mod detect;
mod login;
mod logout;
mod mail;
mod settings;
mod version;
pub use detect::auto_detect_config as auto_detect_config_handler;
pub use login::login as login_handler;
pub use logout::logout as logout_handler;
pub use settings::settings as settings_handler;
pub use version::version as version_handler;
pub use mail::*;

@ -0,0 +1,29 @@
use rocket::{serde::Serialize, State};
use crate::{
state::{AuthType, Config},
types::{OkResponse, ResponseResult},
};
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
pub struct SettingsResponse {
authorization: bool,
authorization_type: Option<AuthType>,
}
#[get("/settings")]
pub fn settings(config: &State<Config>) -> ResponseResult<SettingsResponse> {
let auth_enabled = config.authorization().is_some();
let authorization_type = config
.authorization()
.map(|auth_config| auth_config.auth_type().clone());
let settings_response = SettingsResponse {
authorization: auth_enabled,
authorization_type,
};
Ok(OkResponse::new(settings_response))
}

@ -0,0 +1,20 @@
use rocket::serde::Serialize;
use crate::types::{OkResponse, ResponseResult};
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
pub struct VersionResponse {
version: String,
r#type: String,
}
#[get("/version")]
pub fn version() -> ResponseResult<VersionResponse> {
let response = VersionResponse {
version: "0.2.4".into(),
r#type: "git".into(),
};
Ok(OkResponse::new(response))
}

@ -41,6 +41,7 @@
"@mui/styled-engine": "^5.8.0",
"@tauri-apps/api": "^1.2.0",
"axios": "^0.27.2",
"js-md5": "^0.7.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^8.0.3",
@ -60,6 +61,7 @@
"@size-limit/preset-app": "^7.0.8",
"@tauri-apps/cli": "^1.0.4",
"@types/jest": "^28.1.4",
"@types/js-md5": "^0.7.0",
"@types/node": "^17.0.35",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",

@ -25,7 +25,6 @@ import modalStyles from "@styles/modal";
import scrollbarStyles from "@styles/scrollbar";
import useBoxes from "@utils/hooks/useBoxes";
import useHttpClient from "@utils/hooks/useFetch";
import useSnackbar from "@utils/hooks/useSnackbar";
import useStore from "@utils/hooks/useStore";
import useTheme from "@utils/hooks/useTheme";
@ -77,8 +76,6 @@ const UnMemoizedAddBox: FC = () => {
const unifiedBoxes = checkedBoxesStore((state) => state.checkedBoxes);
const fetcher = useHttpClient();
const { boxes, error: boxesError, findBox } = useBoxes();
const [error, setError] = useState<string>();
@ -134,17 +131,17 @@ const UnMemoizedAddBox: FC = () => {
// if (!box.unifies) {
setFetching(true);
await fetcher
.createBox(box.id)
.then(() => {
showSnackbar(`Folder '${box.name}' created`);
setShowAddBox(false);
})
.catch((error: AxiosError<{ message: string }>) => {
const message = error.response?.data.message;
if (message) setError(message);
});
// await fetcher
// .createBox(box.id)
// .then(() => {
// showSnackbar(`Folder '${box.name}' created`);
// setShowAddBox(false);
// })
// .catch((error: AxiosError<{ message: string }>) => {
// const message = error.response?.data.message;
// if (message) setError(message);
// });
setFetching(false);
// }

@ -2,10 +2,6 @@ import create from "zustand";
import { FC, memo, useState } from "react";
import { AxiosError } from "axios";
import { ErrorResponse } from "@dust-mail/typings";
import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
@ -14,7 +10,6 @@ import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import useHttpClient from "@utils/hooks/useFetch";
import useSnackbar from "@utils/hooks/useSnackbar";
import useStore from "@utils/hooks/useStore";
@ -29,8 +24,6 @@ export const deleteBoxStore = create<DeleteBoxStore>((set) => ({
}));
const UnMemoizedDeleteBox: FC = () => {
const fetcher = useHttpClient();
const openSnackbar = useSnackbar();
const showDeleteItemsDialog = useStore(
@ -56,19 +49,19 @@ const UnMemoizedDeleteBox: FC = () => {
const deleteSelectedItems = async (): Promise<void> => {
setFetching(true);
await fetcher
.deleteBox(boxesToDelete)
.then(() => {
openSnackbar(`Folder(s) '${boxesToDelete.join("', '")}' deleted`);
// await fetcher
// .deleteBox(boxesToDelete)
// .then(() => {
// openSnackbar(`Folder(s) '${boxesToDelete.join("', '")}' deleted`);
deleteItemsDialogOnClose();
setBoxesToDelete([]);
})
.catch((error: AxiosError<ErrorResponse>) => {
const errorMessage = error.response?.data.message;
// deleteItemsDialogOnClose();
// setBoxesToDelete([]);
// })
// .catch((error: AxiosError<ErrorResponse>) => {
// const errorMessage = error.response?.data.message;
if (errorMessage) setError(errorMessage);
});
// if (errorMessage) setError(errorMessage);
// });
setFetching(false);
};

@ -2,10 +2,6 @@ import create from "zustand";
import { FC, memo, useState } from "react";
import { AxiosError } from "axios";
import { ErrorResponse } from "@dust-mail/typings";
import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
@ -16,7 +12,6 @@ import TextField from "@mui/material/TextField";
import Box from "@interfaces/box";
import useHttpClient from "@utils/hooks/useFetch";
import useSnackbar from "@utils/hooks/useSnackbar";
import useStore from "@utils/hooks/useStore";
@ -40,8 +35,6 @@ const UnMemoizedRenameBox: FC = () => {
const boxToRename = renameBoxStore((state) => state.boxToRename);
const fetcher = useHttpClient();
const openSnackbar = useSnackbar();
const [newName, setNewName] = useState("");
@ -71,22 +64,22 @@ const UnMemoizedRenameBox: FC = () => {
setFetching(true);
await fetcher
.renameBox(boxToRename.id, newBoxID)
.then(() => {
openSnackbar(`Folder '${boxToRename.name}' renamed to '${newName}'`);
// await fetcher
// .renameBox(boxToRename.id, newBoxID)
// .then(() => {
// openSnackbar(`Folder '${boxToRename.name}' renamed to '${newName}'`);
// const newBox: Box = { ...boxToRename, id: newBoxID, name: newName };
// // const newBox: Box = { ...boxToRename, id: newBoxID, name: newName };
// modifyBox(boxToRename.id, newBox);
// // modifyBox(boxToRename.id, newBox);
handleClose();
})
.catch((e: AxiosError<ErrorResponse>) => {
const errorMessage = e.response?.data.message;
// handleClose();
// })
// .catch((e: AxiosError<ErrorResponse>) => {
// const errorMessage = e.response?.data.message;
if (errorMessage) setError(errorMessage);
});
// if (errorMessage) setError(errorMessage);
// });
setFetching(false);
};

@ -2,10 +2,6 @@ import { FC, memo } from "react";
import ReactMarkdown from "react-markdown";
import { useQuery } from "react-query";
import { AxiosError } from "axios";
import { PackageError } from "@dust-mail/typings";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import Modal from "@mui/material/Modal";
@ -13,9 +9,10 @@ import Typography from "@mui/material/Typography";
import modalStyles from "@styles/modal";
import useHttpClient from "@utils/hooks/useFetch";
import useApiClient from "@utils/hooks/useApiClient";
import useStore from "@utils/hooks/useStore";
import useTheme from "@utils/hooks/useTheme";
import { createResultFromUnknown, errorToString } from "@utils/parseError";
const UnMemoizedChangelog: FC = () => {
const theme = useTheme();
@ -23,12 +20,23 @@ const UnMemoizedChangelog: FC = () => {
const showChangelog = useStore((state) => state.showChangelog);
const setShowChangelog = useStore((state) => state.setShowChangelog);
const fetcher = useHttpClient();
const { data, error, isFetching } = useQuery<
string,
AxiosError<PackageError>
>(["changelog"], () => fetcher.getChangelog(), { enabled: showChangelog });
const apiClient = useApiClient();
const { data, error, isFetching } = useQuery<string, string>(
["changelog"],
async () => {
const result = await apiClient
.getChangelog()
.catch(createResultFromUnknown);
if (result.ok) {
return result.data;
} else {
throw errorToString(result.error);
}
},
{ enabled: showChangelog }
);
return (
<Modal onClose={() => setShowChangelog(false)} open={showChangelog}>
@ -36,9 +44,7 @@ const UnMemoizedChangelog: FC = () => {
sx={{ ...modalStyles(theme), maxHeight: "75vh", overflowY: "scroll" }}
>
<Typography variant="h4">Changelog</Typography>
{error && error.response?.data.message && (
<Typography>{error.response.data.message}</Typography>
)}
{error !== null && <Typography>{error}</Typography>}
{isFetching && <CircularProgress />}
{data && <ReactMarkdown>{data}</ReactMarkdown>}
</Box>

@ -1,32 +1,105 @@
import useLocalStorageState from "use-local-storage-state";
import z from "zod";
import { repository } from "../../../package.json";
import { FC } from "react";
import { FC, useCallback, useEffect } from "react";
import { useState } from "react";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import FormControl from "@mui/material/FormControl";
import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment";
import InputLabel from "@mui/material/InputLabel";
import Link from "@mui/material/Link";
import Modal from "@mui/material/Modal";
import OutlinedInput from "@mui/material/OutlinedInput";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import CheckIcon from "@mui/icons-material/Check";
import ErrorIcon from "@mui/icons-material/Error";
import RefreshIcon from "@mui/icons-material/Refresh";
import SettingsIcon from "@mui/icons-material/Settings";
import { ApiSettingsModel } from "@models/api/settings";
import { Error as ErrorModel } from "@models/error";
import { Result } from "@interfaces/result";
import modalStyles from "@styles/modal";
import useApiClient from "@utils/hooks/useApiClient";
import useSettings from "@utils/hooks/useSettings";
import useTheme from "@utils/hooks/useTheme";
import { createResultFromUnknown, errorToString } from "@utils/parseError";
const LoginSettingsMenu: FC = () => {
const theme = useTheme();
const [settings, setSetting] = useSettings();
const [isOpen, setOpen] = useState(false);
const [apiUrl, setApiUrl] = useState(settings.httpServerUrl ?? "");
const [password, setPassword] = useState("");
const apiClient = useApiClient();
const [connectError, setConnectError] = useState<z.infer<
typeof ErrorModel
> | null>(null);
const [serverSettings, setServerSettings] = useState<z.infer<
typeof ApiSettingsModel
> | null>(null);
const [fetching, setFetching] = useState(false);
const [loginError, setLoginError] = useState<z.infer<
typeof ErrorModel
> | null>(null);
const fetchApiSettings = useCallback(
async (baseUrl: string): Promise<void> => {
setFetching(true);
setConnectError(null);
setServerSettings(null);
const response = await apiClient
.getSettings(baseUrl)
.catch(createResultFromUnknown);
setFetching(false);
if (response.ok) {
setServerSettings(response.data);
} else {
setConnectError(response.error);
}
},
[]
);
const [customServerUrl, setCustomServerUrl] =
useLocalStorageState("customServerUrl");
const loginToApiServer = useCallback(
async (
baseUrl?: string,
password?: string,
username?: string
): Promise<Result<void>> =>
await apiClient
.login(baseUrl, password, username)
.catch(createResultFromUnknown),
[]
);
useEffect(() => {
if (isOpen) fetchApiSettings(apiUrl);
}, [isOpen]);
useEffect(() => {
setLoginError(null);
}, [isOpen, password, apiUrl]);
return (
<>
@ -72,18 +145,66 @@ const LoginSettingsMenu: FC = () => {
</Typography>
</Box>
<TextField
fullWidth
onChange={(e) => setCustomServerUrl(e.currentTarget.value)}
value={customServerUrl}
id="custom-server"
label="Custom server url/path"
variant="outlined"
type="text"
/>
<Stack direction="row" spacing={2}>
<FormControl fullWidth variant="outlined">
<InputLabel htmlFor="custom-server">
Custom server url/path
</InputLabel>
<OutlinedInput
onChange={(e) =>
setApiUrl(z.string().parse(e.currentTarget.value))
}
value={apiUrl}
id="custom-server"
label="Custom server url/path"
type="text"
endAdornment={
<InputAdornment position="end">
{connectError !== null && <ErrorIcon color="error" />}
{serverSettings !== null && (
<CheckIcon color="success" />
)}
{fetching && <CircularProgress />}
</InputAdornment>
}
/>
</FormControl>
<IconButton onClick={() => fetchApiSettings(apiUrl)}>
<RefreshIcon />
</IconButton>
</Stack>
{serverSettings?.authorization && (
<Stack direction="column">
<TextField
fullWidth
onChange={(e) =>
setPassword(z.string().parse(e.currentTarget.value))
}
value={password}
id="password"
required
label="Password for server"
variant="outlined"
type="password"
/>
</Stack>
)}
{loginError !== null && (
<Alert severity="error">{errorToString(loginError)}</Alert>
)}
<Button
onClick={() => setOpen(false)}
onClick={async () => {
const loginResult = await loginToApiServer(apiUrl, password);
if (loginResult.ok) {
setSetting("httpServerUrl", apiUrl);
setOpen(false);
} else {
setLoginError(loginResult.error);
}
}}
fullWidth
variant="contained"
>
@ -91,12 +212,8 @@ const LoginSettingsMenu: FC = () => {
</Button>
<Button
onClick={() =>
setCustomServerUrl(import.meta.env.VITE_DEFAULT_SERVER)
}
disabled={
customServerUrl == import.meta.env.VITE_DEFAULT_SERVER
}
onClick={() => setApiUrl(import.meta.env.VITE_DEFAULT_SERVER)}
disabled={apiUrl == import.meta.env.VITE_DEFAULT_SERVER}
>
Reset to default value
</Button>

@ -4,12 +4,11 @@ import { FC, memo, MouseEvent, useMemo, useState } from "react";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Skeleton from "@mui/material/Skeleton";
import Typography from "@mui/material/Typography";
import { Preview } from "@models/preview";
import useAvatar from "@utils/hooks/useAvatar";
import createAvatarUrl from "@utils/avatarUrl";
import { useSetSelectedMessage } from "@utils/hooks/useSelectedMessage";
import useTheme from "@utils/hooks/useTheme";
@ -55,7 +54,12 @@ const UnMemoizedMessageListItem: FC<{
[message]
);
const avatar = useAvatar(from.length != 0 ? message.from[0].address : null);
const primarySender = message.from[0] ?? null;
const avatar =
primarySender?.address !== null
? createAvatarUrl(primarySender.address)
: undefined;
const handleClick = useMemo(
() => (): void => {
@ -110,22 +114,14 @@ const UnMemoizedMessageListItem: FC<{
></Box>
)}
<Box sx={{ m: 2, ml: 3 }}>
{avatar?.isLoading ? (
<Skeleton variant="rectangular">
<Avatar />
</Skeleton>
) : (
<Avatar
sx={{
bgcolor: !avatar?.data ? theme.palette.secondary.main : null
}}
variant="rounded"
src={avatar?.data}
alt={from.charAt(0).toUpperCase()}
>
{!avatar?.data && from.charAt(0).toUpperCase()}
</Avatar>
)}
<Avatar
sx={{
bgcolor: theme.palette.secondary.main
}}
variant="rounded"
src={avatar}
alt={from.charAt(0).toUpperCase()}
/>
</Box>
<Box sx={{ flex: 1, minWidth: 0, mr: 1 }}>
<Typography noWrap textOverflow="ellipsis" variant="body2">

@ -36,7 +36,8 @@ import { Address } from "@models/address";
import scrollbarStyles from "@styles/scrollbar";
import useAvatar from "@utils/hooks/useAvatar";
import createAvatarUrl from "@utils/avatarUrl";
// import useAvatar from "@utils/hooks/useAvatar";
import useMessageActions from "@utils/hooks/useMessageActions";
import useSelectedMessage, {
useSetSelectedMessage
@ -50,7 +51,7 @@ const AddressListItem: FC<{ address: string | null; name: string | null }> = ({
}) => {
const theme = useTheme();
const avatar = useAvatar(address);
const avatar = address !== null ? createAvatarUrl(address) : undefined;
const displayName = name || address || "Unknown";
@ -65,13 +66,11 @@ const AddressListItem: FC<{ address: string | null; name: string | null }> = ({
<Avatar
sx={{
mr: 2,
bgcolor: !avatar ? theme.palette.secondary.main : null
bgcolor: theme.palette.secondary.main
}}
src={avatar?.data}
src={avatar}
alt={displayName.charAt(0).toUpperCase()}
>
{!avatar?.data && displayName.charAt(0).toLocaleUpperCase()}
</Avatar>
/>
}
label={
displayName == address ? displayName : `${displayName} <${address}>`

@ -20,7 +20,7 @@ import SettingsIcon from "@mui/icons-material/Settings";
import User from "@interfaces/user";
import useAvatar from "@utils/hooks/useAvatar";
import createAvatarUrl from "@utils/avatarUrl";
import useLogout from "@utils/hooks/useLogout";
import useStore from "@utils/hooks/useStore";
import useTheme from "@utils/hooks/useTheme";
@ -48,7 +48,10 @@ const AccountListItem: FC<{ user: User }> = ({ user }) => {
const [currentUser, setCurrentUser] = useCurrentUser();
const avatar = useAvatar(user.usernames.incoming);
const avatar =
user?.usernames.incoming !== undefined
? createAvatarUrl(user?.usernames.incoming)
: undefined;
return (
<MenuItem
@ -66,7 +69,7 @@ const AccountListItem: FC<{ user: User }> = ({ user }) => {
width: theme.spacing(4),
mr: 1
}}
src={avatar?.data}
src={avatar}
alt={user.usernames.incoming.toUpperCase()}
/>
</ListItemIcon>
@ -119,7 +122,10 @@ const UnMemoizedAvatar: FC = () => {
const setShowSettings = useStore((state) => state.setShowSettings);
const avatar = useAvatar(user?.usernames.incoming ?? null);
const avatar =
user?.usernames.incoming !== undefined
? createAvatarUrl(user?.usernames.incoming)
: undefined;
// const setShowMessageComposer = useStore(
// (state) => state.setShowMessageComposer
@ -155,7 +161,7 @@ const UnMemoizedAvatar: FC = () => {
>
<MUIAvatar
sx={{ bgcolor: theme.palette.secondary.main }}
src={avatar?.data}
src={avatar}
alt={user?.usernames.incoming.toUpperCase()}
/>
</IconButton>

@ -0,0 +1,17 @@
import z from "zod";
import { Result } from "./result";
import { ApiSettingsModel } from "@models/api/settings";
export default interface ApiClient {
getChangelog: () => Promise<Result<string>>;
getSettings: (
baseUrl?: string
) => Promise<Result<z.infer<typeof ApiSettingsModel>>>;
login: (
baseUrl?: string,
password?: string,
username?: string
) => Promise<Result<void>>;
}

@ -3,6 +3,8 @@ import z from "zod";
import { MailConfig } from "@models/config";
import { Credentials } from "@models/login";
import { MailBoxList, MailBox } from "@models/mailbox";
import { Message } from "@models/message";
import { Preview } from "@models/preview";
import { Version } from "@models/version";
import { Result } from "@interfaces/result";
@ -15,12 +17,13 @@ export default interface MailClient {
emailAddress: string
) => Promise<Result<z.infer<typeof MailConfig>>>;
login: (options: z.infer<typeof Credentials>) => Promise<Result<string>>;
logout: () => Promise<Result<void>>;
get: (boxId?: string) => Promise<Result<z.infer<typeof MailBox>>>;
list: () => Promise<Result<z.infer<typeof MailBoxList>>>;
messageList: (
page: number,
boxId?: string
) => Promise<Result<z.infer<typeof Preview>>>;
) => Promise<Result<z.infer<typeof Preview>[]>>;
getMessage: (
messageId?: string,
boxId?: string

@ -1,20 +0,0 @@
import {
LoginResponse,
PublicTokensResponse,
MessageCountResponse
} from "@dust-mail/typings";
export default interface HttpClient {
refresh: (refreshToken: string) => Promise<LoginResponse>;
getPublicOAuthTokens: () => Promise<PublicTokensResponse>;
getChangelog: () => Promise<string>;
getAvatar: (address: string | null) => Promise<string | undefined>;
createBox: (id: string) => Promise<void>;
deleteBox: (ids: string[]) => Promise<void>;
renameBox: (oldBoxID: string, newBoxID: string) => Promise<void>;
getMessageCount: (
boxes: string[],
flag: string,
token?: string
) => Promise<MessageCountResponse>;
}

@ -1,3 +1,5 @@
import { z } from "zod";
import { Error as ErrorModel } from "@models/error";
export interface Error {

@ -0,0 +1,19 @@
import z from "zod";
import { Error as ErrorModel } from "../error";
export const OkResponseModel = z.object({
ok: z.literal(true),
data: z.unknown()
});
export const ErrorResponseModel = z.object({
ok: z.literal(false),
error: ErrorModel
});
export const ResponseModel = z.union([ErrorResponseModel, OkResponseModel]);
export type Response =
| z.infer<typeof OkResponseModel>
| z.infer<typeof ErrorModel>;

@ -0,0 +1,6 @@
import z from "zod";
export const ApiSettingsModel = z.object({
authorization: z.boolean(),
authorization_type: z.enum(["password", "user"])
});

@ -0,0 +1,16 @@
import createMd5Hash from "js-md5";
const createAvatarUrl = (email: string): string => {
const hashed = createMd5Hash(email.trim().toLowerCase());
const searchParams = new URLSearchParams();
searchParams.set("s", "64");
searchParams.set("d", "404");
return (
"https://www.gravatar.com/avatar/" + hashed + "?" + searchParams.toString()
);
};
export default createAvatarUrl;

@ -0,0 +1,87 @@
import { z } from "zod";
import { version } from "../../../package.json";
import useFetchClient from "./useFetchClient";
import { ApiSettingsModel } from "@models/api/settings";
import ApiClient from "@interfaces/api";
import { createBaseError, createResultFromUnknown } from "@utils/parseError";
import parseZodOutput from "@utils/parseZodOutput";
const useApiClient = (): ApiClient => {
const fetch = useFetchClient();
return {
async getChangelog() {
const response = await window
.fetch(
`https://raw.githubusercontent.com/${
import.meta.env.VITE_REPO
}/${version}/CHANGELOG.md`,
{ method: "GET" }
)
.then((response) => response.text())
.then((data) => ({ ok: true as const, data }))
.catch((error) => {
return createBaseError({
kind: "GithubError",
message: JSON.stringify(error)
});
});
return response;
},
async getSettings(baseUrl?: string) {
return await fetch("/settings", {
baseUrl,
method: "GET",
sendAuth: false,
useMailSessionToken: false
})
.then((response) => {
if (!response.ok) {
return response;
}
const output = ApiSettingsModel.safeParse(response.data);
return parseZodOutput(output);
})
.catch(createResultFromUnknown);
},
async login(baseUrl, password, username) {
const formData = new FormData();
if (password !== undefined) formData.append("password", password);
if (username !== undefined) formData.append("username", username);
return await fetch("/login", {
method: "POST",
contentType: "none",
baseUrl,
useMailSessionToken: false,
body: formData
})
.then((response) => {
if (!response.ok) {
return response;
}
const output = z.string().safeParse(response.data);
const parsedOutput = parseZodOutput(output);
if (!parsedOutput.ok) {
return parsedOutput;
}
return { ...parsedOutput, data: undefined };
})
.catch(createResultFromUnknown);
}
};
};
export default useApiClient;

@ -1,65 +0,0 @@
import { useMemo } from "react";
import { useQuery } from "react-query";
import { AxiosError } from "axios";
import { ErrorResponse } from "@dust-mail/typings";
// import { AxiosError } from "axios";
import useFetch from "@utils/hooks/useFetch";
/**
* Time to refresh avatars in inbox in seconds
*/
const REFRESH_INBOX_AVATARS = 60 * 60;
export default function useAvatar(
email: string | null
): { data?: string; isLoading: boolean } | void {
const fetcher = useFetch();
const id = ["noAvatar", email].join("-");
let noAvatar: number | undefined;
let setNoAvatar: ((date: number) => void) | undefined;
if (
"sessionStorage" in window &&
"getItem" in sessionStorage &&
"setItem" in sessionStorage
) {
noAvatar = parseInt(sessionStorage.getItem(id) ?? "");
setNoAvatar = (date) => sessionStorage.setItem(id, date.toString());
}
const blacklisted = !!noAvatar;
const { data, isLoading, error } = useQuery<
string | undefined,
AxiosError<ErrorResponse>
>(["avatar", email], () => fetcher.getAvatar(email), {
retry: false,
onError: () => {
return;
},
enabled:
(email != null && !blacklisted) || !!(noAvatar && noAvatar < Date.now())
});
const avatar = useMemo(() => ({ data, isLoading }), [data, isLoading]);
if (!email || email == "") return;
if (
error?.response &&
error.response.status != 401 &&
!data &&
!isLoading &&
(!noAvatar || noAvatar < Date.now()) &&
setNoAvatar
) {
setNoAvatar(Date.now() + REFRESH_INBOX_AVATARS * 1000);
}
return avatar;
}

@ -10,11 +10,7 @@ import { Error } from "@models/error";
import { MailBox, MailBoxList } from "@models/mailbox";
import findBoxFromBoxes from "@utils/findBox";
import {
createBaseError,
createErrorFromUnknown,
errorToString
} from "@utils/parseError";
import { createResultFromUnknown, errorToString } from "@utils/parseError";
type UseBoxes = {
boxes: z.infer<typeof MailBoxList> | void;
@ -31,11 +27,7 @@ const useBoxes = (): UseBoxes => {
z.infer<typeof MailBoxList>,
z.infer<typeof Error>
>(["boxes", user?.id], async () => {
const result = await mailClient
.list()
.catch((error: unknown) =>
createBaseError(createErrorFromUnknown(error))
);
const result = await mailClient.list().catch(createResultFromUnknown);
if (result.ok) {
return result.data;

@ -1,96 +0,0 @@
import useLocalStorageState from "use-local-storage-state";
import { version } from "../../../package.json";
import useUser from "./useUser";
import axios from "axios";
import HttpClient from "@interfaces/http";
const UNIX_PREFIX = "unix://";
const useHttpClient = (token?: string): HttpClient => {
let [backendServer] = useLocalStorageState<string>("customServerUrl", {
defaultValue: import.meta.env.VITE_DEFAULT_SERVER
});
const user = useUser();
const isUnixSocket = backendServer.startsWith(UNIX_PREFIX);
if (isUnixSocket) {
backendServer = backendServer.replace(UNIX_PREFIX, "");
}
const instance = axios.create({
baseURL: backendServer,
headers: {
Authorization: `Bearer ${token ?? user?.token}`
}
});
return {
async getChangelog() {
const { data } = await axios.get(
`https://raw.githubusercontent.com/${
import.meta.env.VITE_REPO
}/${version}/CHANGELOG.md`
);
return data;
},
async refresh(refreshToken) {
const { data } = await instance.get("/auth/refresh", {
headers: { Authorization: `Bearer ${refreshToken}` }
});
return data;
},
async getPublicOAuthTokens() {
const { data } = await instance.get("/auth/oauth/tokens");
return data;
},
async createBox(id: string) {
await instance.put("/mail/folder/create", { id });
},
async deleteBox(ids: string[]) {
await instance.delete("/mail/folder/delete", {
params: { id: ids.join(",") }
});
},
async renameBox(oldBoxID: string, newBoxID: string) {
await instance.put("/mail/folder/rename", {
oldID: oldBoxID,
newID: newBoxID
});
},
async getMessageCount(boxes, flag, token?: string) {
const { data } = await instance.get("/mail/message/count", {
params: {
boxes: boxes.join(","),
flag
},
headers: {
Authorization: `Bearer ${token ?? user?.token}`
}
});
return data;
},
async getAvatar(address) {
const res = await instance.get("/avatar", {
params: { address }
// validateStatus: (status) =>
// (status >= 200 && status < 300)
});
return res?.data;
}
};
};
export default useHttpClient;

@ -0,0 +1,117 @@
import useSettings from "./useSettings";
import useUser from "./useUser";
import { ResponseModel as ApiResponseModel } from "@models/api";
import { Result } from "@interfaces/result";
import { createBaseError, parseError } from "@utils/parseError";
import parseJsonAsync from "@utils/parseJson";
import parseZodOutput from "@utils/parseZodOutput";
type FetchFunction = (
url: string,
config?: {
body?: BodyInit;
params?: Record<string, string>;
baseUrl?: string;
method?: "POST" | "GET" | "DELETE" | "PUT";
sendAuth?: boolean;
useMailSessionToken?: boolean;
contentType?: "json" | "form" | "none";
}
) => Promise<Result<unknown>>;
const useFetchClient = (): FetchFunction => {
const [settings] = useSettings();
const user = useUser();
const httpServerUrl = settings.httpServerUrl;
const fetch: FetchFunction = async (path, config) => {
const backendUrl = config?.baseUrl ?? httpServerUrl;
if (backendUrl == null) {
return createBaseError({
kind: "NoBackendUrl",
message: "Backend url for api server is not set"
});
}
const url = new URL(path, backendUrl);
if (config?.params !== undefined)
Object.entries(config.params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
if (config?.useMailSessionToken !== false && user?.token !== undefined)
url.searchParams.set("session_token", user.token);
if (typeof window === "undefined" || !("fetch" in window)) {
return createBaseError({
kind: "FetchUnsupported",
message: "The fetch api is not supported in this environment"
});
}
const headers = new Headers();
if (config?.contentType === "form") {
headers.append("Content-Type", "application/x-www-form-urlencoded");
} else if (config?.contentType !== "none") {
headers.append("Content-Type", "application/json");
}
return await window
.fetch(url.toString(), {
body: config?.body,
method: config?.method ?? "GET",
credentials: config?.sendAuth !== false ? "include" : "omit",
referrerPolicy: "no-referrer",
headers
})
.then(async (response) => {
const responseString = await response.text().catch(() => null);
if (responseString === null)
return createBaseError({
kind: "InvalidResponseBody",
message: "Invalid response body from server response"
});
return await parseJsonAsync(responseString)
.then((parsedOutput) => {
const parseOutputResult = ApiResponseModel.safeParse(parsedOutput);
const parsedZodOutput = parseZodOutput(parseOutputResult);
if (parsedZodOutput.ok) {
const serverResponseResult = parsedZodOutput.data;
// We have to do this ugly little workaround because the zod unknown parser returns an optional unknown in its ts type.
// See https://github.com/colinhacks/zod/issues/493
if (serverResponseResult.ok) {
return {
...serverResponseResult,
data: serverResponseResult.data as unknown
};
} else {
return serverResponseResult;
}
} else {
return parsedZodOutput;
}
})
.catch((error: string) =>
createBaseError({ kind: "ParseJSON", message: error })
);
})
.catch(parseError);
};
return fetch;
};
export default useFetchClient;

@ -1,3 +1,4 @@
import useMailClient from "./useMailClient";
import useSelectedStore from "./useSelected";
import { useCurrentUser, useRemoveUser, useUsers } from "./useUser";
@ -12,15 +13,19 @@ const useLogout = (): (() => void) => {
const [currentUser, setCurrentUser] = useCurrentUser();
const mailClient = useMailClient();
const setSelectedBox = useSelectedStore((state) => state.setSelectedBox);
const setFetching = useStore((state) => state.setFetching);
const logout = useCallback((): void => {
const logout = useCallback(async (): Promise<void> => {
setFetching(false);
if (!currentUser || !users) return;
await mailClient.logout();
removeUser(currentUser?.id);
const newCurrentUser = users

@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/tauri";
import z from "zod";
import useFetchClient from "./useFetchClient";
import useUser from "./useUser";
import { messageCountForPage } from "@src/constants";
@ -10,12 +11,17 @@ import { Credentials } from "@models/login";
import { MailBox, MailBoxList } from "@models/mailbox";
import { Message } from "@models/message";
import { Preview } from "@models/preview";
import { Version as VersionModel } from "@models/version";
import MailClient from "@interfaces/client";
import { Error } from "@interfaces/result";
import parseEmail from "@utils/parseEmail";
import { createBaseError, parseError } from "@utils/parseError";
import {
createBaseError,
createResultFromUnknown,
parseError
} from "@utils/parseError";
import parseZodOutput from "@utils/parseZodOutput";
const NotLoggedIn = (): Error =>
@ -24,11 +30,13 @@ const NotLoggedIn = (): Error =>
message: "Could not find session token in local storage"
});
const NotImplemented = (): Error =>
createBaseError({
kind: "NotImplemented",
message: "This feature is not yet implemented"
});
// const NotImplemented = (feature?: string): Error =>
// createBaseError({
// kind: "NotImplemented",
// message: `The feature ${
// feature ? `'${feature}'` : ""
// } is not yet implemented`
// });
const MissingRequiredParam = (): Error =>
createBaseError({
@ -41,6 +49,8 @@ const useMailClient = (): MailClient => {
const user = useUser();
const fetch = useFetchClient();
return {