feat(core): added caching, added folder creation

main
Guus van Meerveld 11 months ago
parent 6751095c23
commit 4729f7a15b
Signed by: Guusvanmeerveld
GPG Key ID: 2BA7D7912771966E

@ -1,6 +1,16 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Server + web client",
"type": "node-terminal",
"request": "launch",
"env": {
"VITE_DEFAULT_SERVER": "http://localhost:4000"
},
"command": "pnpm run dev",
"cwd": "${workspaceFolder}"
},
{
"name": "Web Client: start server",
"type": "node-terminal",

@ -42,7 +42,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
switch (payload.services.incoming) {
case "imap":
incomingClient = await this.imapService.getClient(
incomingClient = await this.imapService.get(
(payload.body as MultiConfig).incoming
);
break;
@ -56,7 +56,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
switch (payload.services.outgoing) {
case "smtp":
outgoingClient = await this.smtpService.getClient(
outgoingClient = await this.smtpService.get(
(payload.body as MultiConfig).outgoing
);
break;

@ -79,9 +79,7 @@ export class CacheService implements Cache {
} else {
const data = await fs.readJSON(this.cacheFile);
const value = data[key];
return value;
await fs.writeJSON(this.cacheFile, { ...data, [key]: value });
}
};
}

@ -2,13 +2,13 @@ import GoogleBox from "../interfaces/box";
import axios from "axios";
import { IncomingMessage } from "@dust-mail/typings";
import { BoxResponse, IncomingMessage } from "@dust-mail/typings";
import { Box } from "@mail/interfaces/client/incoming.interface";
export const getBoxes = async (
authorization: string
): Promise<{ name: string; id: string }[]> => {
): Promise<BoxResponse[]> => {
const { data } = await axios.get<{ labels: GoogleBox[] }>(
"https://gmail.googleapis.com/gmail/v1/users/me/labels",
{
@ -18,7 +18,11 @@ export const getBoxes = async (
}
);
return data.labels.map((box) => ({ name: box.name, id: box.id }));
return data.labels.map((box) => ({
name: box.name,
id: box.id,
delimiter: "."
}));
};
export const getBox = async (

@ -29,6 +29,10 @@ export default class IncomingGoogleClient implements IncomingClient {
public getBox = (boxName: string) => getBox(this.authorization, boxName);
public createBox = async (boxID: string): Promise<void> => {
return;
};
public getBoxMessages = async (
boxName: string,
options: { start: number; end: number }

@ -1,5 +1,7 @@
import Imap from "imap";
import { BoxResponse } from "@dust-mail/typings";
import { Box } from "@mail/interfaces/client/incoming.interface";
export const getBox = async (
@ -24,27 +26,39 @@ export const closeBox = async (_client: Imap): Promise<void> => {
});
};
export const getBoxes = async (
_client: Imap
): Promise<{ name: string; id: string }[]> => {
export const getBoxes = async (_client: Imap): Promise<BoxResponse[]> => {
return new Promise((resolve, reject) => {
_client.getBoxes((err, boxes) => {
if (err) reject(err);
else
resolve(
getRecursiveBoxNames(boxes).map((box) => ({ name: box, id: box }))
getRecursiveBoxNames(boxes).map(({ id, delimiter }) => ({
name: id,
id,
delimiter
}))
);
});
});
};
const getRecursiveBoxNames = (boxes: Imap.MailBoxes): string[] =>
export const createBox = async (_client: Imap, boxID: string): Promise<void> =>
await new Promise<void>((resolve, reject) =>
_client.addBox(boxID, (error) => {
if (error) reject(error);
else resolve();
})
);
const getRecursiveBoxNames = (
boxes: Imap.MailBoxes
): { id: string; delimiter: string }[] =>
Object.keys(boxes).reduce((list, box) => {
list.push(box);
list.push({ id: box, delimiter: boxes[box].delimiter });
if (boxes[box].children) {
const childBoxes = getRecursiveBoxNames(boxes[box].children).map(
(name) => `${box}${boxes[box].delimiter}${name}`
({ id, delimiter }) => ({ id: `${box}${delimiter}${id}`, delimiter })
);
list.push(...childBoxes);
}

@ -1,6 +1,6 @@
import Imap from "imap";
import { getBox, closeBox, getBoxes } from "./box";
import { getBox, closeBox, getBoxes, createBox } from "./box";
import fetch, { FetchOptions, search, SearchOptions } from "./fetch";
import Message from "./interfaces/message.interface";
@ -106,6 +106,9 @@ export default class Client implements IncomingClient {
return [];
};
public createBox = async (boxID: string): Promise<void> =>
createBox(this._client, boxID);
public getBoxMessages = async (
boxID: string,
{ filter, start, end }: { filter: string; start: number; end: number }
@ -139,7 +142,7 @@ export default class Client implements IncomingClient {
const cached =
(await this.cacheService.get<IncomingMessageWithInternalID[]>(
cachePath
)) ?? [];
)) || [];
if (cached.length != 0) {
let results: IncomingMessageWithInternalID[] = [];

@ -38,7 +38,7 @@ export class ImapService {
}
});
return await connect(client).then((_client) => {
return await connect(client).then(async (_client) => {
const identifier = createIdentifier(config);
if (!this.clients.get(identifier)) this.clients.set(identifier, _client);
@ -47,7 +47,7 @@ export class ImapService {
});
};
public getClient = async (config: Config): Promise<IncomingClient> => {
public get = async (config: Config): Promise<IncomingClient> => {
const identifier = createIdentifier(config);
let client = this.clients.get(identifier);
@ -59,7 +59,7 @@ export class ImapService {
return new Client(client, this.cacheService, identifier);
};
public logout = (config: Config): void => {
public logout = async (config: Config): Promise<void> => {
const identifier = createIdentifier(config);
const client = this.clients.get(identifier);

@ -23,4 +23,5 @@ export default interface IncomingClient {
noImages: boolean,
darkMode: boolean
) => Promise<FullIncomingMessage | void>;
createBox: (boxID: string) => Promise<void>;
}

@ -3,5 +3,5 @@ import { OutgoingMessage } from "@dust-mail/typings";
export default interface OutgoingClient {
send: (message: OutgoingMessage) => Promise<void>;
connect: () => Promise<void>;
logout: () => Promise<void>;
disconnect: () => Promise<void>;
}

@ -13,6 +13,7 @@ import {
Body,
Controller,
Get,
Put,
ParseBoolPipe,
ParseIntPipe,
Post,
@ -68,13 +69,15 @@ export class MailController {
filter = "";
}
const boxes = box.split(",");
const client = req.user.incomingClient;
const start = page * limit;
const end = page * limit + limit - 1;
return await client
.getBoxMessages(box, {
.getBoxMessages(boxes.shift(), {
filter,
start,
end
@ -84,10 +87,15 @@ export class MailController {
.filter((msg) => msg.id != undefined)
.map((message) => ({
...message,
date: new Date(message.date),
id: Buffer.from(message.id, "utf-8").toString("base64")
}))
)
.catch(handleError);
);
// return allMessages
// .flat()
// .sort((a, b) => b.date.getTime() - a.date.getTime())
// .slice(start, end);
}
@Get("message")
@ -116,6 +124,23 @@ export class MailController {
return await client.getMessage(id, box, markAsRead, noImages, darkMode);
}
@Put("folder/create")
@UseGuards(AccessTokenAuthGuard)
async createBox(
@Req() req: Request,
@Body("id", StringValidationPipe) boxID: string
) {
if (boxID == undefined) {
throw new BadRequestException("Missing folder `name` param");
}
const client = req.user.incomingClient;
await client.createBox(boxID).catch(handleError);
return "created new folder";
}
@Post("send")
// @UseGuards(ThrottlerBehindProxyGuard)
@UseGuards(AccessTokenAuthGuard)
@ -152,13 +177,11 @@ export class MailController {
const client = req.user.outgoingClient;
await client.connect();
await client
.send({ from, to, cc, bcc, content, subject })
.catch(handleError);
await client.logout();
await client.disconnect();
return "sent";
}

@ -11,19 +11,21 @@ import OutgoingClient from "@mail/interfaces/client/outgoing.interface";
import Config from "@auth/interfaces/config.interface";
export default class Client implements OutgoingClient {
private _client: Transporter;
constructor(
private readonly cacheService: CacheService,
private readonly config: Config
) {}
private _client: Transporter;
private readonly authTimeout = 30 * 1000;
public connect = async (): Promise<void> => {
this._client = nodemailer.createTransport({
host: this.config.server,
port: this.config.port,
secure: this.config.security == "TLS",
connectionTimeout: 10 * 1000,
connectionTimeout: this.authTimeout,
auth: {
user: this.config.username,
pass: this.config.password
@ -31,7 +33,7 @@ export default class Client implements OutgoingClient {
});
};
public logout = async (): Promise<void> => {
public disconnect = async (): Promise<void> => {
this._client.close();
};

@ -13,6 +13,6 @@ export class SmtpService {
@Inject("CACHE")
private readonly cacheService: CacheService;
public getClient = async (config: Config): Promise<OutgoingClient> =>
public get = async (config: Config): Promise<OutgoingClient> =>
new Client(this.cacheService, config);
}

@ -2,9 +2,8 @@ import { createHash } from "@utils/createHash";
import Config from "@auth/interfaces/config.interface";
export function createIdentifier(config: Config): string {
return createHash(
export const createIdentifier = (config: Config): string =>
createHash(
`${config.username}:${config.password}@${config.server}:${config.port}`,
"sha256"
"sha512"
);
}

@ -1,7 +1,10 @@
import useLocalStorageState from "use-local-storage-state";
import { FC, useMemo, useState } from "react";
import { FC, useEffect, useMemo, useState } from "react";
import { AxiosError } from "axios";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import FormControl from "@mui/material/FormControl";
@ -21,9 +24,12 @@ import MailBox from "@interfaces/box";
import modalStyles from "@styles/modal";
import scrollbarStyles from "@styles/scrollbar";
import flattenBoxesArray from "@utils/flattenBoxesArray";
import flattenBoxes from "@utils/flattenBoxes";
import useHttpClient from "@utils/hooks/useFetch";
import useSnackbar from "@utils/hooks/useSnackbar";
import useStore from "@utils/hooks/useStore";
import useTheme from "@utils/hooks/useTheme";
import nestBoxes from "@utils/nestBoxes";
import FolderTree, {
checkedBoxesStore,
@ -37,12 +43,18 @@ const AddBox: FC = () => {
const [boxes, setBoxes] = useLocalStorageState<MailBox[]>("boxes");
const showSnackbar = useSnackbar();
const setShowAddBox = useStore((state) => state.setShowAddBox);
const showAddBox = useStore((state) => state.showAddBox);
const unifiedBoxes = checkedBoxesStore((state) => state.checkedBoxes);
const addUnifiedBox = checkedBoxesStore((state) => state.setChecked);
const fetcher = useHttpClient();
const [error, setError] = useState<string>();
const checkedBoxes = useMemo(
() => Object.entries(unifiedBoxes).filter(([, checked]) => checked),
[unifiedBoxes]
@ -50,14 +62,16 @@ const AddBox: FC = () => {
const [folderType, setFolderType] = useState<FolderType>("none");
const [parentFolder, setParentFolder] = useState<string>("none");
const [parentFolder, setParentFolder] = useState<MailBox>();
const [folderName, setFolderName] = useState<string>("");
const handleClose = (): void => setShowAddBox(false);
const boxIDs = useMemo(() => {
if (boxes) return flattenBoxesArray(boxes);
useEffect(() => setError(undefined), [folderType, parentFolder, folderName]);
const flattenedBoxes = useMemo(() => {
if (boxes) return flattenBoxes(boxes);
else return [];
}, boxes);
@ -67,166 +81,194 @@ const AddBox: FC = () => {
);
const checkAllBoxes = (checked: boolean): void => {
const ids = boxIDs.map((box) => box.id);
const ids = flattenedBoxes.map((box) => box.id);
ids.forEach((id) => addUnifiedBox(id, checked));
};
const createBox = (box: MailBox): void => {
if (boxes) setBoxes([...boxes, box]);
const createBox = async (box: MailBox): Promise<void> => {
if (flattenedBoxes) {
flattenedBoxes.push(box);
setBoxes(nestBoxes(flattenedBoxes));
}
if (!box.unifies)
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);
});
};
return (
<Modal open={showAddBox} onClose={handleClose}>
<Stack
spacing={2}
direction="column"
sx={{
...modalSx,
...scrollbarSx,
maxHeight: "90%",
overflowY: "scroll"
}}
>
<Typography gutterBottom variant="h3">
Create a new folder
</Typography>
<FormControl fullWidth>
<InputLabel id="folder-type-label">Folder type</InputLabel>
<Select
labelId="folder-type-label"
id="folder-type"
value={folderType}
label="Folder type"
onChange={(e) => setFolderType(e.target.value as FolderType)}
>
<MenuItem value="none">None</MenuItem>
<MenuItem value="unified">
Unified
<Typography
sx={{
ml: 2,
display: "inline",
color: theme.palette.text.secondary
}}
>
Create a (local) inbox that unifies together multiple inboxes
</Typography>
</MenuItem>
<MenuItem value="normal">
Normal
<Typography
sx={{
ml: 2,
display: "inline",
color: theme.palette.text.secondary
}}
>
Create a new inbox on the server
</Typography>
</MenuItem>
</Select>
</FormControl>
{folderType != "none" && (
<TextField
fullWidth
value={folderName}
onChange={(e) => setFolderName(e.target.value)}
label="Folder name"
/>
)}
{folderType != "none" && (
<>
<Modal open={showAddBox} onClose={handleClose}>
<Stack
spacing={2}
direction="column"
sx={{
...modalSx,
...scrollbarSx,
maxHeight: "90%",
overflowY: "scroll"
}}
>
<Typography gutterBottom variant="h3">
Create a new folder
</Typography>
<FormControl fullWidth>
<InputLabel id="parent-folder-label">Parent folder</InputLabel>
<InputLabel id="folder-type-label">Folder type</InputLabel>
<Select
labelId="parent-folder-label"
id="parent-folder"
value={parentFolder}
label="Parent folder"
onChange={(e) => setParentFolder(e.target.value)}
MenuProps={{
sx: {
maxHeight: 300
}
}}
labelId="folder-type-label"
id="folder-type"
value={folderType}
label="Folder type"
onChange={(e) => setFolderType(e.target.value as FolderType)}
>
<MenuItem value="none">None</MenuItem>
{boxIDs.map((box, i) => (
<MenuItem key={box.id + i} value={box.id}>
{box.name}
</MenuItem>
))}
<MenuItem value="unified">
Unified
<Typography
sx={{
ml: 2,
display: "inline",
color: theme.palette.text.secondary
}}
>
Create a (local) inbox that unifies together multiple inboxes
</Typography>
</MenuItem>
<MenuItem value="normal">
Normal
<Typography
sx={{
ml: 2,
display: "inline",
color: theme.palette.text.secondary
}}
>
Create a new inbox on the server
</Typography>
</MenuItem>
</Select>
</FormControl>
)}
{folderType == "unified" && boxes && (
<>
<Stack direction="column" justifyContent="left" spacing={2}>
<Typography variant="h5">
Select the folders you want to be unified
</Typography>
<Stack direction="row" alignItems="center" spacing={2}>
<Button
onClick={() => checkAllBoxes(true)}
variant="outlined"
startIcon={<SelectAllIcon />}
>
Select all folders
</Button>
<Button
onClick={() => checkAllBoxes(false)}
variant="outlined"
startIcon={<DeselectAllIcon />}
>
Deselect all folders
</Button>
{folderType != "none" && (
<TextField
fullWidth
value={folderName}
onChange={(e) => setFolderName(e.target.value)}
label="Folder name"
/>
)}
{folderType != "none" && (
<FormControl fullWidth>
<InputLabel id="parent-folder-label">Parent folder</InputLabel>
<Select
labelId="parent-folder-label"
id="parent-folder"
value={parentFolder?.id ?? "none"}
label="Parent folder"
onChange={(e) => {
const parentFolder = flattenedBoxes.find(
(box) => box.id == e.target.value
);
setParentFolder(parentFolder);
}}
MenuProps={{
sx: {
maxHeight: 300
}
}}
>
<MenuItem value="none">None</MenuItem>
{flattenedBoxes.map((box, i) => (
<MenuItem key={box.id + i} value={box.id}>
{box.id.split(box.delimiter).join(" / ")}
</MenuItem>
))}
</Select>
</FormControl>
)}
{folderType == "unified" && boxes && (
<>
<Stack direction="column" justifyContent="left" spacing={2}>
<Typography variant="h5">
Select the folders you want to be unified
</Typography>
<Stack direction="row" alignItems="center" spacing={2}>
<Button
onClick={() => checkAllBoxes(true)}
variant="outlined"
startIcon={<SelectAllIcon />}
>
Select all folders
</Button>
<Button
onClick={() => checkAllBoxes(false)}
variant="outlined"
startIcon={<DeselectAllIcon />}
>
Deselect all folders
</Button>
</Stack>
</Stack>
</Stack>
<Box
sx={{
...scrollbarSx,
maxHeight: "15rem",
overflowY: "scroll"
}}
>
<CheckedBoxesContext.Provider value={checkedBoxesStore}>
<FolderTree showCheckBox boxes={boxes} />
</CheckedBoxesContext.Provider>
</Box>
</>
)}
<Button
disabled={
folderType == "none" ||
folderName == "" ||
parentFolder == "none" ||
(folderType == "unified" && checkedBoxes.length == 0)
}
onClick={() =>
createBox({
name: folderName,
id: folderName,
unifies:
folderType == "unified"
? boxIDs.length == checkedBoxes.length
? "all"
: checkedBoxes.map((box) => box[0])
: undefined
})
}
variant="contained"
>
Create
</Button>
</Stack>
</Modal>
<Box
sx={{
...scrollbarSx,
maxHeight: "15rem",
overflowY: "scroll"
}}
>
<CheckedBoxesContext.Provider value={checkedBoxesStore}>
<FolderTree showCheckBox boxes={boxes} />
</CheckedBoxesContext.Provider>
</Box>
</>
)}
<Button
disabled={
folderType == "none" ||
folderName == "" ||
parentFolder == undefined ||
(folderType == "unified" && checkedBoxes.length == 0)
}
onClick={() => {
if (!parentFolder) return;
createBox({
name: folderName,
id: parentFolder.id + parentFolder.delimiter + folderName,
delimiter: parentFolder.delimiter,
unifies:
folderType == "unified"
? checkedBoxes.map((box) => box[0])
: undefined
});
}}
variant="contained"
>
Create
</Button>
{error && <Alert severity="error">{error}</Alert>}
</Stack>
</Modal>
</>
);
};

@ -104,7 +104,7 @@ const UnMemoizedListItem: FC<
const indent = useMemo(
() =>
theme.spacing(
(showCheckBox ? 0 : 2) + box.id.split(box.delimiter ?? ".").length
(showCheckBox ? 0 : 2) + box.id.split(box.delimiter).length
),
[theme.spacing, box.id, showCheckBox]
);

@ -28,7 +28,7 @@ import FolderTree, {
const UnMemoizedBoxesList: FC<{ clickOnBox?: (e: MouseEvent) => void }> = ({
clickOnBox
}) => {
const [boxes] = useLocalStorageState<{ name: string; id: string }[]>("boxes");
const [boxes] = useLocalStorageState<MailBox[]>("boxes");
const [selectedBox, setSelectedBox] = useSelectedBox();
@ -51,7 +51,7 @@ const UnMemoizedBoxesList: FC<{ clickOnBox?: (e: MouseEvent) => void }> = ({
(): FolderTreeProps => ({
showCheckBox: showSelector,
onClick: (box, e) => {
setSelectedBox(box);
setSelectedBox(box.id);
if (clickOnBox) clickOnBox(e);
}
}),

@ -0,0 +1,65 @@
import useLocalStorageState from "use-local-storage-state";
import { FC, memo, useEffect, useMemo } from "react";
import { useQuery } from "react-query";
import { LocalToken, LoginResponse } from "@dust-mail/typings";
import useFetch from "@utils/hooks/useFetch";
import useLogin from "@utils/hooks/useLogin";
import useLogout from "@utils/hooks/useLogout";
import useStore from "@utils/hooks/useStore";
const UnMemoizedLoginStateHandler: FC = () => {
const [accessToken] = useLocalStorageState<LocalToken>("accessToken");
const [refreshToken] = useLocalStorageState<LocalToken>("refreshToken");
const setFetching = useStore((state) => state.setFetching);
const fetching = useStore((state) => state.fetching);
const fetcher = useFetch();
const logout = useLogout();
const login = useLogin();
const accessTokenExpired = useMemo(
() =>
accessToken != undefined &&
new Date(accessToken.expires).getTime() < Date.now(),
[fetching, accessToken]
);
if (
accessTokenExpired &&
refreshToken &&
new Date(refreshToken?.expires).getTime() < Date.now()
) {
logout();
}
const {
data: tokens,
error: tokensError,
isFetching: isFetchingTokens
} = useQuery<LoginResponse>(
"refreshTokens",
() => fetcher.refresh(refreshToken?.body),
{
enabled: !!(accessTokenExpired && refreshToken)
}
);
useEffect(() => {
if (tokensError) setFetching(false);
else setFetching(isFetchingTokens);
}, [isFetchingTokens, tokensError]);
useEffect(() => {
if (tokens && accessTokenExpired) login(tokens);
}, [tokens]);
return <></>;
};
const LoginStateHandler = memo(UnMemoizedLoginStateHandler);
export default LoginStateHandler;

@ -12,6 +12,7 @@ import Typography from "@mui/material/Typography";
import MenuIcon from "@mui/icons-material/Menu";
import NavigateNext from "@mui/icons-material/NavigateNext";
import useBoxes from "@utils/hooks/useBoxes";
import useSelectedBox from "@utils/hooks/useSelectedBox";
import useStore from "@utils/hooks/useStore";
import useTheme from "@utils/hooks/useTheme";
@ -28,6 +29,8 @@ const UnMemoizedNavbar: FC = () => {
const [selectedBox, setSelectedBox] = useSelectedBox();
const [flattenedBoxes] = useBoxes();
const toggleDrawer = useMemo(
() => (open: boolean) => (event: KeyboardEvent | MouseEvent) => {
if (
@ -60,29 +63,32 @@ const UnMemoizedNavbar: FC = () => {
);
const breadcrumbs = useMemo(() => {
const boxNameSplit = selectedBox?.name.split(".");
const boxIDSplit = selectedBox?.id.split(selectedBox.delimiter);
return boxIDSplit?.map((crumb, i) => {
const boxID = boxIDSplit.slice(0, i + 1).join(".");
const boxName = flattenedBoxes?.find((box) => box.id == boxID)?.name;
return boxNameSplit?.map((crumb, i) => {
const boxName = boxNameSplit.slice(0, i + 1).join(".");
const isSelectedBox = boxID == selectedBox?.id;
return (
<Typography
sx={{
color: boxName == selectedBox?.name ? primaryColor : secondaryColor,
cursor: boxName == selectedBox?.name ? "inherit" : "pointer"
color: isSelectedBox ? primaryColor : secondaryColor,
cursor: isSelectedBox ? "inherit" : "pointer"
}}
key={boxName}
key={boxID}
onClick={() => {
if (boxName == selectedBox?.name) return;
if (isSelectedBox) return;
setSelectedBox({ id: boxName, name: boxName });
setSelectedBox(boxID);
}}
>
{crumb}
{boxName ?? "Unknown box"}
</Typography>
);
});
}, [selectedBox?.name, primaryColor, secondaryColor]);
}, [selectedBox, primaryColor, secondaryColor]);
return (
<>

@ -0,0 +1,64 @@
import create from "zustand";
import { FC } from "react";
import Alert from "@mui/material/Alert";
// import IconButton from "@mui/material/IconButton";
import MaterialSnackbar from "@mui/material/Snackbar";
// import CloseIcon from "@mui/icons-material/Close";
interface SnackbarStore {
message: string;
setMessage: (message: string) => void;
open: boolean;
setOpen: (open: boolean) => void;
}
export const useSnackbarStore = create<SnackbarStore>((set) => ({
message: "",
setMessage: (message) => set({ message }),
open: false,
setOpen: (open) => set({ open })
}));
const Snackbar: FC = () => {
const open = useSnackbarStore((state) => state.open);
const message = useSnackbarStore((state) => state.message);
const setOpen = useSnackbarStore((state) => state.setOpen);
const handleClose = (
event: React.SyntheticEvent | Event,
reason?: string
): void => {
if (reason === "clickaway") {
return;
}
setOpen(false);
};
// const SnackBarActions = (
// <>
// <IconButton
// size="small"
// aria-label="close"
// color="inherit"
// onClick={handleClose}
// >
// <CloseIcon fontSize="small" />
// </IconButton>
// </>
// );
return (
<MaterialSnackbar open={open} autoHideDuration={5000} onClose={handleClose}>
<Alert onClose={handleClose} severity="success" sx={{ width: "100%" }}>
{message}
</Alert>
</MaterialSnackbar>
);
};
export default Snackbar;

@ -3,6 +3,6 @@ import { BoxResponse } from "@dust-mail/typings";
export default interface Box extends BoxResponse {
icon?: JSX.Element;
children?: Box[];
unifies?: string[] | "all";
delimiter?: string;
unifies?: string[];
delimiter: string;
}

@ -21,6 +21,7 @@ export default interface HttpClient {
pageParam: number,
filter: string
) => Promise<IncomingMessage[]>;
createBox: (id: string) => Promise<void>;
getMessage: (
noImages: boolean,
darkMode: boolean,

@ -1,81 +1,32 @@
import useLocalStorageState from "use-local-storage-state";
import { FC, useEffect, MouseEvent, useMemo } from "react";
import { useQuery } from "react-query";
import { FC, MouseEvent, useMemo } from "react";
import { Navigate } from "react-router-dom";
import { LocalToken, LoginResponse } from "@dust-mail/typings";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import scrollbarStyles from "@styles/scrollbar";
import useFetch from "@utils/hooks/useFetch";
import useLogin from "@utils/hooks/useLogin";
import useLogout from "@utils/hooks/useLogout";
import useStore from "@utils/hooks/useStore";
import useTheme from "@utils/hooks/useTheme";
import useUser from "@utils/hooks/useUser";
import useWindowWidth from "@utils/hooks/useWindowWidth";
import BoxesList from "@components/Boxes/List";
import Layout from "@components/Layout";
import LoginStateHandler from "@components/LoginStateHandler";
import MessageActionButton from "@components/Message/ActionButton";
import MessageList from "@components/Message/List";
import MessageOverview from "@components/Message/Overview";
import Snackbar from "@components/Snackbar";
const defaultMessageListWidth = 400;
const Dashboard: FC = () => {
const theme = useTheme();
const [accessToken] = useLocalStorageState<LocalToken>("accessToken");
const [refreshToken] = useLocalStorageState<LocalToken>("refreshToken");
const setFetching = useStore((state) => state.setFetching);
const fetcher = useFetch();
const logout = useLogout();
const login = useLogin();
const scrollBarSx = useMemo(() => scrollbarStyles(theme), [theme]);
const accessTokenExpired = useMemo(
() => accessToken && new Date(accessToken?.expires).getTime() < Date.now(),
[accessToken, Date.now()]
);
if (
accessTokenExpired &&
refreshToken &&
new Date(refreshToken?.expires).getTime() < Date.now()
) {
logout();
}
const {
data: tokens,
error: tokensError,
isFetching: isFetchingTokens
} = useQuery<LoginResponse>(
"refreshTokens",
() => fetcher.refresh(refreshToken?.body),
{
enabled: !!(accessTokenExpired && refreshToken)
}
);
useEffect(() => {
if (tokensError) setFetching(false);
else setFetching(isFetchingTokens);
}, [isFetchingTokens, tokensError]);
useEffect(() => {
if (tokens) login(tokens);
}, [tokens]);
const user = useUser();
const [messageListWidth, setMessageListWidth] = useLocalStorageState<number>(
@ -145,6 +96,8 @@ const Dashboard: FC = () => {
return (
<>
{!user.isLoggedIn && <Navigate to="/" replace={true} />}
<LoginStateHandler />
<Snackbar />
<Layout withNavbar>
<Stack direction="row" sx={{ height: fullpageHeight }}>
{!isMobile && (

@ -38,7 +38,9 @@ const DEFAULT_PRIMARY_BOXES: {
{ name: "Updates", id: "CATEGORY_UPDATES", icon: <Updates /> }
];
const findBoxInPrimaryBoxesList = (id: string): Box | undefined => {
const findBoxInPrimaryBoxesList = (
id: string
): Omit<Box, "delimiter"> | undefined => {
const foundBox = DEFAULT_PRIMARY_BOXES.find((box) => {
if (Array.isArray(box.id)) {
const found = box.id.find(

@ -0,0 +1,30 @@
import Box from "@interfaces/box";
import findBoxInPrimaryBoxesList from "@utils/findBoxInPrimaryBoxesList";
/**
* The opposite of `nestBoxes.ts`
*
* Take an array of boxes that have `children` properties and flattens it so that there are no more children and all of the boxes are at the top level.
* @param boxes - The unflattened array with nested children
* @returns An array of boxes with all of the items at the top level
*/
const flattenBoxes = (boxes: Box[]): Box[] =>
boxes.reduce<any[]>((list, { name, id, delimiter }) => {
list.push({ name, id, delimiter, ...findBoxInPrimaryBoxesList(id) });
const currentBox = boxes.find((box) => box.name == name)?.children;
if (currentBox) {
const childBoxes = flattenBoxes(currentBox).map((child) => ({
...child,
name: child.name
}));
list.push(...childBoxes);
}
return list;
}, []);
export default flattenBoxes;

@ -1,23 +0,0 @@
import Box from "@interfaces/box";
import findBoxInPrimaryBoxesList from "@utils/findBoxInPrimaryBoxesList";
const flattenBoxesArray = (boxes: Box[]): Box[] =>
boxes.reduce<any[]>((list, { name, id }) => {
list.push({ name, id, ...findBoxInPrimaryBoxesList(name) });
const currentBox = boxes.find((box) => box.name == name)?.children;
if (currentBox) {
const childBoxes = flattenBoxesArray(currentBox).map((child) => ({
name: `${name} / ${child.name}`,
id: child.id
}));
list.push(...childBoxes);
}
return list;
}, []);
export default flattenBoxesArray;

@ -1,5 +1,3 @@
import useLocalStorageState from "use-local-storage-state";
import { useQuery } from "react-query";
// import { AxiosError } from "axios";
@ -15,9 +13,11 @@ export default function useAvatar(
): { data?: string; isLoading: boolean } | void {
const fetcher = useFetch();
const [noAvatar, setNoAvatar] = useLocalStorageState<number>(
["noAvatar", email].join("-")
);
const id = ["noAvatar", email].join("-");
const noAvatar = parseInt(sessionStorage.getItem(id) ?? "");
const setNoAvatar = (date: number): void =>
sessionStorage.setItem(id, date.toString());
const blacklisted = !!noAvatar;

@ -0,0 +1,17 @@
import useLocalStorageState from "use-local-storage-state";
import Box from "@interfaces/box";
import flattenBoxes from "@utils/flattenBoxes";
const useBoxes = (): [flattenedBoxes: Box[], boxes: Box[]] | [] => {
const [boxes] = useLocalStorageState<Box[]>("boxes");
if (!boxes) return [];
const flattenedBoxes = flattenBoxes(boxes);
return [flattenedBoxes, boxes];
};
export default useBoxes;

@ -105,6 +105,9 @@ const useHttpClient = (): HttpClient => {
return data;
},
async createBox(id: string) {
await instance.put("/mail/folder/create", { id });
},
async getMessage(noImages, darkMode, messageID, boxID) {
const { data } = await instance.get("/mail/message", {
params: {

@ -16,12 +16,13 @@ import Box from "@interfaces/box";
import { LoginConfig } from "@interfaces/login";
import createGravatarUrl from "@utils/createGravatarUrl";
import findBoxInPrimaryBoxesList from "@utils/findBoxInPrimaryBoxesList";
import useFetch from "@utils/hooks/useFetch";
import useStore from "@utils/hooks/useStore";
import parseBoxes from "@utils/parseBoxes";
import nestBoxes from "@utils/nestBoxes";
/**
* Request the users inboxes and puts them in local storage
* A hook that request the users inboxes and puts them in local storage
*/
const useFetchBoxes = (): ((token: string) => Promise<void>) => {
const fetcher = useFetch();
@ -33,7 +34,20 @@ const useFetchBoxes = (): ((token: string) => Promise<void>) => {
const boxes = await fetcher.getBoxes(token);
setBoxes(parseBoxes(boxes));
setBoxes(
nestBoxes(
boxes.map((box) => {
const foundBox = findBoxInPrimaryBoxesList(box.id);
if (!foundBox) return box;
return {
...box,
name: foundBox?.name ?? box.name
};
})
)
);
};
};

@ -1,3 +1,5 @@
import useSnackbar from "./useSnackbar";
import DeleteIcon from "@mui/icons-material/Delete";
import FolderMoveIcon from "@mui/icons-material/DriveFileMove";
import ForwardIcon from "@mui/icons-material/Forward";
@ -7,6 +9,7 @@ import ReplyIcon from "@mui/icons-material/Reply";
import MessageAction from "@interfaces/messageAction";
const useMessageActions = (messageID?: string): MessageAction[] => {
const showSnackbar = useSnackbar();
if (!messageID) return [];
const actions: MessageAction[] = [
@ -18,7 +21,7 @@ const useMessageActions = (messageID?: string): MessageAction[] => {
{
name: "Forward message",
icon: <ForwardIcon />,
handler: () => null
handler: () => showSnackbar("yeet")
},
{
name: "Reply",

@ -5,37 +5,37 @@ import { useNavigate, useParams } from "react-router-dom";
import Box from "@interfaces/box";
const useSelectedBox = (): [Box | void, (boxID?: Box) => void] => {
import flattenBoxes from "@utils/flattenBoxes";
const useSelectedBox = (): [Box | void, (boxID?: string) => void] => {
const params = useParams<{ boxID: string }>();