feat(erver): added caching for messages, improved login

main
Guus van Meerveld 1 year ago
parent 72af4acf23
commit 8cb1219311
Signed by: Guusvanmeerveld
GPG Key ID: 2BA7D7912771966E

@ -20,6 +20,8 @@
"^preact$",
"^preact.*",
"^axios.*",
"^redis.*",
"@redis/.*",
"@dust-mail/.*",
"^@nestjs/.*",
"^@emotion/.*",
@ -31,6 +33,7 @@
"^@styles/.*",
"^@shared/.*",
"^@utils/.*",
"^@cache/.*",
"^@mail/.*",
"^@auth/.*",
"^@components/.*",

@ -30,7 +30,6 @@
"@nestjs/serve-static": "^3.0.0",
"@nestjs/throttler": "^2.0.1",
"@redis/client": "^1.2.0",
"@redis/json": "^1.0.3",
"axios": "^0.27.2",
"cache-manager": "^4.0.1",
"cheerio": "1.0.0-rc.12",
@ -51,7 +50,8 @@
"rxjs": "^7.5.5",
"sanitize-html": "^2.7.0",
"ua-parser-js": "^1.0.2",
"validator": "^13.7.0"
"validator": "^13.7.0",
"xoauth2": "^1.2.0"
},
"devDependencies": {
"@dust-mail/tsconfig": "workspace:*",

@ -1,22 +1,16 @@
body,
tbody,
div,
span,
p,
table,
td,
h1,
h2,
h3,
h4,
h5,
h6,
center {
* {
background-color: #000 !important;
color: #fff !important;
border-color: #2a2a2a !important;
}
a,
a * {
background-color: #000 !important;
color: #8ab4f8 !important;
}
a:hover,
a *:hover {
color: #76a9fb !important;
}

@ -9,7 +9,7 @@ import { StringValidationPipe } from "./pipes/string.pipe";
import mailDiscover, { detectServiceFromConfig } from "@dust-mail/autodiscover";
import {
LoginResponse,
UserError,
GatewayError,
IncomingServiceType,
OutgoingServiceType
} from "@dust-mail/typings";
@ -19,6 +19,7 @@ import {
Body,
Controller,
Get,
InternalServerErrorException,
Post,
Req,
UnauthorizedException,
@ -76,8 +77,7 @@ export class AuthController {
const result = await mailDiscover(incomingUsername).catch(() => {
const server = incomingUsername.split("@").pop() as string;
incomingServer = "mail." + server;
outgoingServer = "mail." + server;
if (!incomingServer) incomingServer = "mail." + server;
});
if (result) {
@ -99,25 +99,27 @@ export class AuthController {
}
}
if (!outgoingServer) outgoingServer = incomingServer;
if (
this.allowedDomains &&
((incomingServer && !this.allowedDomains.includes(incomingServer)) ||
(outgoingServer && !this.allowedDomains.includes(outgoingServer)))
) {
throw new BadRequestException({
code: UserError.Misc,
code: GatewayError.Misc,
message: "Mail server is not on whitelist"
});
}
if (!incomingSecurity) incomingSecurity = "NONE";
if (!incomingSecurity) incomingSecurity = "TLS";
if (!incomingPort) {
if (incomingSecurity == "TLS" || incomingSecurity == "STARTTLS")
incomingPort = 993;
if (incomingSecurity == "NONE") incomingPort = 143;
}
if (!outgoingSecurity) outgoingSecurity = "NONE";
if (!outgoingSecurity) outgoingSecurity = "TLS";
if (!outgoingPort) {
if (outgoingSecurity == "TLS") outgoingPort = 465;
if (outgoingSecurity == "STARTTLS") outgoingPort = 587;
@ -133,7 +135,7 @@ export class AuthController {
!this.allowedAddresses.includes(outgoingUsername))
) {
throw new BadRequestException({
code: UserError.Misc,
code: GatewayError.Misc,
message: "Email address is not on whitelist"
});
}
@ -146,7 +148,7 @@ export class AuthController {
}).catch(handleError)) as IncomingServiceType;
}
if (!outgoingService) {
if (!outgoingService && outgoingServer) {
outgoingService = (await detectServiceFromConfig({
security: outgoingSecurity,
port: outgoingPort,
@ -154,6 +156,9 @@ export class AuthController {
}).catch(handleError)) as OutgoingServiceType;
}
if (incomingService == "pop3")
throw new InternalServerErrorException("Pop3 is not supported yet");
return await this.authService
.login({
incoming: {
@ -179,7 +184,7 @@ export class AuthController {
throw new BadRequestException("Missing fields");
}
private bearerPrefix = "Bearer ";
private readonly bearerPrefix = "Bearer ";
@Get("refresh")
public async refreshTokens(
@ -202,9 +207,9 @@ export class AuthController {
throw new UnauthorizedException("Refresh token is invalid");
});
if (refreshTokenPayload.tokenType == "access")
if (refreshTokenPayload.tokenType != "refresh")
throw new UnauthorizedException(
"Can't use access token as refresh token"
"Can't use any other token as refresh token"
);
return await this.jwtService

@ -2,7 +2,11 @@ import { ExtractJwt, Strategy } from "passport-jwt";
import { JwtToken, MultiConfig } from "./interfaces/jwt.interface";
import { Injectable, UnauthorizedException } from "@nestjs/common";
import {
Injectable,
InternalServerErrorException,
UnauthorizedException
} from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { jwtConstants } from "@src/constants";
@ -29,9 +33,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
async validate(payload: JwtToken) {
if (payload.tokenType == "refresh")
if (payload.tokenType != "access")
throw new UnauthorizedException(
"Can't use refresh token as access token"
"Can't use any other token as access token"
);
let incomingClient: IncomingClient, outgoingClient: OutgoingClient;
@ -44,7 +48,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
break;
case "pop3":
break;
throw new InternalServerErrorException("Pop3 is not supported yet");
default:
break;

@ -1,9 +1,10 @@
import fs from "fs-extra";
import { join } from "path";
import { createClient, RedisClientType } from "redis";
import { getCacheTimeout, getJsonCacheDir, getRedisUri } from "./constants";
import Cache, { getter, initter, setter, writer } from "./interfaces/cache";
import Cache, { getter, initter, setter } from "./interfaces/cache";
import { createClient, RedisClientType } from "redis";
import { Injectable } from "@nestjs/common";
@ -19,8 +20,6 @@ export class CacheService implements Cache {
private readonly cacheTimeout: number;
private data: Record<string, any>;
constructor() {
this.cacheTimeout = getCacheTimeout();
@ -31,6 +30,9 @@ export class CacheService implements Cache {
else this.cacheFile = getCacheFile();
}
private createKey = (path: string[]) =>
Buffer.from(path.join("."), "utf-8").toString("base64");
public init: initter = async () => {
if (this.isRedisCache) {
await this.redisClient.connect();
@ -40,14 +42,10 @@ export class CacheService implements Cache {
if (
await fs
.readJSON(this.cacheFile)
.then((currentCache) => {
this.data = currentCache;
.then(() => {
return false;
})
.catch(() => {
this.data = {};
return true;
})
)
@ -55,16 +53,35 @@ export class CacheService implements Cache {
}
};
// public get: getter = async (path: string[]) => {
// return {};
// };
public get: getter = async <T>(path: string[]): Promise<T | undefined> => {
const key = this.createKey(path);
if (this.isRedisCache) {
const data = await this.redisClient.get(key);
if (data) return JSON.parse(data) as T;
} else {
const data = await fs.readJSON(this.cacheFile);
const value = data[key];
// public set: setter = async (path: string[], value: string) => {};
return value;
}
};
public set: setter = async <T>(path: string[], value: T): Promise<void> => {
const key = this.createKey(path);
public write: writer = async () => {
if (this.isRedisCache) {
await this.redisClient.set(key, JSON.stringify(value));
await this.redisClient.expire(key, this.cacheTimeout / 1000);
} else {
await fs.outputJSON(this.cacheFile, this.data);
const data = await fs.readJSON(this.cacheFile);
const value = data[key];
return value;
}
};
}

@ -1,11 +1,9 @@
export type getter = <T>(path: string[]) => T;
export type setter = (path: string[], value: string) => void;
export type getter = <T>(path: string[]) => Promise<T | undefined>;
export type setter = <T>(path: string[], value: T) => Promise<void>;
export type initter = () => Promise<void>;
export type writer = () => Promise<void>;
export default interface Cache {
init: initter;
// get: getter;
// set: setter;
write: writer;
get: getter;
set: setter;
}

@ -1,5 +1,3 @@
import { CacheModule } from "@cache/cache.module";
import { GoogleController } from "./google.controller";
import { GoogleService } from "./google.service";
@ -8,6 +6,8 @@ import { JwtModule } from "@nestjs/jwt";
import { jwtConstants } from "@src/constants";
import { CacheModule } from "@cache/cache.module";
@Module({
imports: [
JwtModule.registerAsync({

@ -1,5 +1,3 @@
import { CacheService } from "@cache/cache.service";
import IncomingGoogleClient from "./incoming";
import Config from "./interfaces/config";
import exchangeToken from "./utils/exchangeToken";
@ -12,6 +10,8 @@ import { JwtService } from "@nestjs/jwt";
import { jwtConstants } from "@src/constants";
import { CacheService } from "@cache/cache.service";
import IncomingClient from "@mail/interfaces/client/incoming.interface";
import OutgoingClient from "@mail/interfaces/client/outgoing.interface";

@ -88,6 +88,7 @@ export const getBoxMessages = async (
)?.value,
from,
id: data.id,
box: { id: "" },
flags: {
seen: true
}

@ -1,5 +1,3 @@
import { CacheService } from "@cache/cache.service";
import GoogleConfig from "../interfaces/config";
import { getBox, getBoxes, getBoxMessages } from "./box";
import connect from "./connect";
@ -7,6 +5,8 @@ import { getMessage } from "./message";
import { IncomingMessage } from "@dust-mail/typings";
import { CacheService } from "@cache/cache.service";
import IncomingClient from "@mail/interfaces/client/incoming.interface";
export default class IncomingGoogleClient implements IncomingClient {

@ -10,7 +10,10 @@ export const getMessage = async (
content: {},
date: new Date(),
flags: { seen: true },
box: {
id: Buffer.from(Math.random().toString(), "utf8").toString("base64")
},
from: [],
id: ""
id: Buffer.from(Math.random().toString(), "utf8").toString("base64")
};
};

@ -2,6 +2,7 @@ import Imap from "imap";
import { getBox, closeBox, getBoxes } from "./box";
import fetch, { FetchOptions, search, SearchOptions } from "./fetch";
import Message from "./interfaces/message.interface";
import {
IncomingMessage,
@ -9,19 +10,31 @@ import {
FullIncomingMessage
} from "@dust-mail/typings";
import { CacheService } from "@src/cache/cache.service";
import { InternalServerErrorException } from "@nestjs/common";
import parseMessage, { createAddress } from "@src/imap/utils/parseMessage";
import cleanMainHtml, { cleanTextHtml } from "@utils/cleanHtml";
import uniqueBy from "@utils/uniqueBy";
import { CacheService } from "@cache/cache.service";
import { getCacheTimeout } from "@cache/constants";
import IncomingClient from "@mail/interfaces/client/incoming.interface";
type IncomingMessageWithInternalID = IncomingMessage & { internalID: number };
export default class Client implements IncomingClient {
constructor(
private readonly _client: Imap,
private readonly cacheService: CacheService
) {}
private readonly cacheService: CacheService,
private readonly identifier: string
) {
this.cacheTimeout = getCacheTimeout();
}
private readonly cacheTimeout: number;
private readonly headerBody = "HEADER.FIELDS (FROM SUBJECT MESSAGE-ID)";
public getBoxes = () => getBoxes(this._client);
@ -44,19 +57,66 @@ export default class Client implements IncomingClient {
private search = (options: SearchOptions) => search(this._client, options);
private parseImapMessage = (
fetched: Message,
boxID: string,
body: string
): IncomingMessage => {
const message = fetched.bodies.find(
(message) => message.which == body
).body;
return {
...parseMessage(message),
flags: { seen: !!fetched.flags.find((flag) => flag.match(/Seen/)) },
date: fetched.date,
box: { id: boxID }
};
};
private checkForNewMessages = async (
existingMessages: IncomingMessageWithInternalID[],
boxID: string
): Promise<IncomingMessageWithInternalID[]> => {
const possibleNewMessages = await this.search({
filters: [["SENTSINCE", new Date(Date.now() - this.cacheTimeout)]]
});
const possibleNewMessageIDs = possibleNewMessages.filter(
(message) =>
!existingMessages.find(
(existingMessage) => existingMessage.internalID == message
)
);
if (possibleNewMessageIDs.length != 0) {
const newMessages: IncomingMessageWithInternalID[] = await this.fetch({
id: possibleNewMessageIDs,
bodies: this.headerBody
}).then((results) =>
results.map((message, i) => ({
...this.parseImapMessage(message, boxID, this.headerBody),
internalID: possibleNewMessageIDs[i]
}))
);
return newMessages;
}
return [];
};
public getBoxMessages = async (
boxName: string,
boxID: string,
{ filter, start, end }: { filter: string; start: number; end: number }
): Promise<IncomingMessage[]> => {
const box = await this.getBox(boxName);
const box = await this.getBox(boxID);
const totalMessages = box.totalMessages;
if (totalMessages <= start) return [];
const headerBody = "HEADER.FIELDS (FROM SUBJECT MESSAGE-ID)";
let fetchOptions: FetchOptions = { bodies: headerBody };
let fetchOptions: FetchOptions = { bodies: this.headerBody };
if (filter.length != 0)
fetchOptions.id = await this.search({
@ -74,22 +134,79 @@ export default class Client implements IncomingClient {
else fetchOptions.id = fetchOptions.id.slice(0, end - start + 1);
}
let results = await this.fetch(fetchOptions).then((results) =>
results.map((message) => {
const parsed = {
...parseMessage(
message.bodies.find((body) => body.which == headerBody).body
),
flags: { seen: !!message.flags.find((flag) => flag.match(/Seen/)) },
date: message.date
};
return parsed;
})
const cachePath = [this.identifier, "messages", boxID];
const cached =
(await this.cacheService.get<IncomingMessageWithInternalID[]>(
cachePath
)) ?? [];
if (cached.length != 0) {
let results: IncomingMessageWithInternalID[] = [];
if (fetchOptions.id) {
results = cached.filter(
(item) => fetchOptions.id.indexOf(item.internalID) != -1
);
} else {
results = cached.filter(
(item) =>
fetchOptions.start >= item.internalID &&
fetchOptions.end <= item.internalID
);
}
if (results.length != 0) {
// Check if there are new messages
if (start == 0) {
const newMessages = await this.checkForNewMessages(results, boxID);
if (newMessages.length != 0) {
results = [...newMessages, ...results];
await this.cacheService.set<IncomingMessageWithInternalID[]>(
cachePath,
results
);
}
}
console.log("cached");
return results;
}
}
console.log("not cached");
let results: IncomingMessage[] = await this.fetch(fetchOptions).then(
(results) =>
results.map((message) =>
this.parseImapMessage(message, boxID, this.headerBody)
)
);
results = uniqueBy(results, (key) => key.id);
let newCache: IncomingMessageWithInternalID[];
if (fetchOptions.id) {
newCache = results.map((item, i) => ({
...item,
internalID: fetchOptions.id[i]
}));
} else {
newCache = results.map((item, i) => ({
...item,
internalID: fetchOptions.start - i
}));
}
await this.cacheService.set<IncomingMessageWithInternalID[]>(cachePath, [
...cached,
...newCache
]);
// await this.closeBox();
return results;
@ -110,7 +227,8 @@ export default class Client implements IncomingClient {
filters: [["HEADER", "Message-ID", id]]
});
if (ids.length == 0) return;
if (ids.length == 0)
throw new InternalServerErrorException("Message not found");
const messages = await this.fetch({
id: ids,
@ -148,7 +266,8 @@ export default class Client implements IncomingClient {
? result.body.to
.map((address) => address.value.map(createAddress))
.flat()
: result.body.to?.value.map(createAddress)
: result.body.to?.value.map(createAddress),
box: { id: boxName }
}));
return {

@ -85,6 +85,6 @@ export interface FetchOptions {
markAsRead?: boolean;
}
export type SearchOptions = { filters: (string | string[])[] };
export type SearchOptions = { filters: any[] };
export default fetch;

@ -18,6 +18,8 @@ export class ImapService {
this.clients = new Map();
}
private readonly authTimeout = 30 * 1000;
@Inject("CACHE")
private readonly cacheService: CacheService;
@ -30,7 +32,7 @@ export class ImapService {
host: config.server,
port: config.port,
tls: config.security != "NONE",
authTimeout: 30 * 1000,
authTimeout: this.authTimeout,
tlsOptions: {
rejectUnauthorized: false
}
@ -54,7 +56,7 @@ export class ImapService {
client = await this.login(config);
}
return new Client(client, this.cacheService);
return new Client(client, this.cacheService, identifier);
};
public logout = (config: Config): void => {

@ -1,9 +1,9 @@
import { CacheService } from "@cache/cache.service";
import Client from "./client";
import { Inject, Injectable } from "@nestjs/common";
import { CacheService } from "@cache/cache.service";
import OutgoingClient from "@mail/interfaces/client/outgoing.interface";
import Config from "@auth/interfaces/config.interface";

@ -42,14 +42,16 @@ const cleanMainHtml = (
allowVulnerableTags: true
});
if (darkMode) {
const $ = load(html);
const $ = load(html);
if (darkMode) {
$("head").append(`<style>${styles.toString()}</style>`);
html = $.html();
}
$("a").attr("target", "_blank");
html = $.html();
html = minify(html);
return html;

@ -1,4 +1,4 @@
import { UserError, PackageError } from "@dust-mail/typings";
import { ErrorResponse, GatewayError, PackageError } from "@dust-mail/typings";
import {
BadGatewayException,
@ -7,44 +7,46 @@ import {
UnauthorizedException
} from "@nestjs/common";
export const parseError = (error: PackageError): UserError => {
export const parseError = (error: PackageError): GatewayError => {
// console.log(error);
if (error.source == "socket") {
return UserError.Network;
}
if (error?.source) {
if (error.source == "socket") {
return GatewayError.Network;
}
if (error.source == "timeout") {
return UserError.Timeout;
}
if (error.source == "timeout") {
return GatewayError.Timeout;
}
if (error.source == "authentication") {
return UserError.Credentials;
}
if (error.source == "authentication") {
return GatewayError.Credentials;
}
if (error.source == "protocol") {
return UserError.Protocol;
if (error.source == "protocol") {
return GatewayError.Protocol;
}
}
return UserError.Misc;
return GatewayError.Misc;
};
const handleError = (error: PackageError) => {
const errorType = parseError(error);
const errorMessage = {
const errorMessage: ErrorResponse = {
code: errorType,
message: error.message
message: error?.message ?? "none"
};
switch (errorType) {
case UserError.Credentials:
case GatewayError.Credentials:
throw new UnauthorizedException(errorMessage);
case UserError.Timeout:
case GatewayError.Timeout:
throw new GatewayTimeoutException(errorMessage);
case UserError.Network || UserError.Protocol:
case GatewayError.Network || GatewayError.Protocol:
throw new BadGatewayException(errorMessage);
default:

@ -5,17 +5,16 @@ import { FC, useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import FormControl from "@mui/material/FormControl";
import IconButton from "@mui/material/IconButton";
import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
import Modal from "@mui/material/Modal";
import Select from "@mui/material/Select";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import CheckBoxOutlineBlank from "@mui/icons-material/CheckBoxOutlineBlank";
import SelectAllIcon from "@mui/icons-material/CheckBox";
import DeselectAllIcon from "@mui/icons-material/CheckBoxOutlineBlank";
import MailBox from "@interfaces/box";
@ -36,11 +35,19 @@ type FolderType = "unified" | "normal" | "none";
const AddBox: FC = () => {
const theme = useTheme();
const [boxes] = useLocalStorageState<MailBox[]>("boxes");
const [boxes, setBoxes] = useLocalStorageState<MailBox[]>("boxes");
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 checkedBoxes = useMemo(
() => Object.entries(unifiedBoxes).filter(([, checked]) => checked),
[unifiedBoxes]
);
const [folderType, setFolderType] = useState<FolderType>("none");
const [parentFolder, setParentFolder] = useState<string>("none");
@ -54,9 +61,33 @@ const AddBox: FC = () => {
else return [];
}, boxes);
const [modalSx, scrollbarSx] = useMemo(
() => [modalStyles(theme), scrollbarStyles(theme)],
[theme]
);
const checkAllBoxes = (checked: boolean): void => {
const ids = boxIDs.map((box) => box.id);
ids.forEach((id) => addUnifiedBox(id, checked));
};
const createBox = (box: MailBox): void => {
if (boxes) setBoxes([...boxes, box]);
};
return (
<Modal open={showAddBox} onClose={handleClose}>
<Stack spacing={2} direction="column" sx={modalStyles(theme)}>
<Stack
spacing={2}
direction="column"
sx={{
...modalSx,
...scrollbarSx,
maxHeight: "90%",
overflowY: "scroll"
}}
>
<Typography gutterBottom variant="h3">
Create a new folder
</Typography>
@ -134,20 +165,32 @@ const AddBox: FC = () => {
{folderType == "unified" && boxes && (
<>
<Stack direction="row" alignItems="center" spacing={1}>
<Stack direction="column" justifyContent="left" spacing={2}>
<Typography variant="h5">
Select the boxes you want to be unified
Select the folders you want to be unified
</Typography>
<Tooltip title="Select all boxes">
<IconButton>
<CheckBoxOutlineBlank />
</IconButton>
</Tooltip>
<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>
<Box
sx={{
...scrollbarStyles(theme),
...scrollbarSx,
maxHeight: "15rem",
overflowY: "scroll"
}}
@ -161,10 +204,23 @@ const AddBox: FC = () => {
<Button
disabled={
folderType == "none" || folderName == "" || parentFolder == "none"
// (folderType == "unified" && unifiedBoxes.length == 0)
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
})
}
onClick={() => console.log("yeet")}
variant="contained"
>
Create

@ -19,6 +19,7 @@ import MUIListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import ListSubheader from "@mui/material/ListSubheader";
import Typography from "@mui/material/Typography";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
@ -53,12 +54,21 @@ export interface FolderTreeProps {
const UnMemoizedFolderTree: FC<
{
boxes: MailBox[];
title?: string;
} & FolderTreeProps
> = ({ boxes, ...props }) => {
> = ({ boxes, title, ...props }) => {
const [selectedBox] = useSelectedBox();
return (
<List>
<List
subheader={
title ? (
<ListSubheader component="div" id={`subheader-${title}`}>
{title}
</ListSubheader>
) : null
}
>
{boxes.map((box) => (
<ListItem
{...props}

@ -119,10 +119,20 @@ const UnMemoizedBoxesList: FC<{ clickOnBox?: (e: MouseEvent) => void }> = ({
<CheckedBoxesContext.Provider value={checkedBoxesStore}>
{(primaryBoxes || otherBoxes) && <Divider />}
{primaryBoxes && (
<FolderTree {...folderTreeProps} boxes={primaryBoxes} />
<FolderTree
title="Primary folders"
{...folderTreeProps}
boxes={primaryBoxes}
/>
)}
{primaryBoxes && <Divider />}
{otherBoxes && <FolderTree {...folderTreeProps} boxes={otherBoxes} />}
{otherBoxes && (
<FolderTree
title="Other folders"
{...folderTreeProps}
boxes={otherBoxes}
/>
)}
</CheckedBoxesContext.Provider>
</>
);

@ -2,9 +2,17 @@ import create from "zustand";
import { description } from "../../../package.json";
import { useEffect, useState, FC, memo, FormEvent, useMemo } from "react";
import { ErrorResponse, UserError } from "@dust-mail/typings";
import {
useEffect,
useState,
FC,
memo,
FormEvent,
useMemo,
FormEventHandler
} from "react";
import { ErrorResponse, GatewayError } from "@dust-mail/typings";
import Alert from "@mui/material/Alert";
import AlertTitle from "@mui/material/AlertTitle";
@ -47,8 +55,12 @@ type Store = Record<ServerType, AdvancedLogin & { error?: ErrorResponse }> & {
};
const createLoginSettingsStore = create<Store>((set) => ({
incoming: {},
outgoing: {},
incoming: {
security: "TLS"
},
outgoing: {
security: "TLS"
},
setProperty: (type) => (property) => (newValue) =>
set((state) => ({ [type]: { ...state[type], [property]: newValue } }))
}));
@ -75,9 +87,9 @@ const Credentials: FC<{
setUsername(e.currentTarget.value);
}}
id={"username-" + identifier}
error={error && error.type == UserError.Credentials}
error={error && error.code == GatewayError.Credentials}
helperText={
error && error.type == UserError.Credentials && error.message
error && error.code == GatewayError.Credentials && error.message
}
label="Username"
variant="outlined"
@ -85,7 +97,7 @@ const Credentials: FC<{
/>
<FormControl
error={error && error.type == UserError.Credentials}
error={error && error.code == GatewayError.Credentials}
required={required}
variant="outlined"
>
@ -150,7 +162,7 @@ const UnMemoizedServerPropertiesColumn: FC<{
labelId={`${type}-server-security-label`}
id={`${type}-server-security`}
label="Security"
value={security ?? "NONE"}
value={security}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange={(e: any) => setSetting(type)("security")(e.target?.value)}
>
@ -210,18 +222,29 @@ const AdvancedLoginMenu: FC = () => {
const setProperty = createLoginSettingsStore((state) => state.setProperty);
const fetching = useStore((state) => state.fetching);
const error = createLoginSettingsStore((state) => state.incoming.error);
const incoming = createLoginSettingsStore((state) => state.incoming);
const outgoing = createLoginSettingsStore((state) => state.outgoing);
const missingFields = !incoming.username || !incoming.password;
const missingFields = useMemo(() => {
return !incoming.username || !incoming.password;
}, [incoming.username, incoming.password]);
const serverTypes = useMemo(
() => ["incoming", "outgoing"] as ServerType[],
[]
);
const onSubmit: FormEventHandler = async (e): Promise<void> => {
e.preventDefault();
const submit = async (): Promise<void> => {
if (missingFields) {
setProperty("incoming")("error")({
message: "Missing required fields",
type: UserError.Misc
code: GatewayError.Misc
});
return;
@ -246,7 +269,7 @@ const AdvancedLoginMenu: FC = () => {
maxHeight: "90%"
}}
>
<form onSubmit={submit}>
<form onSubmit={onSubmit}>
<Typography variant="h5" textAlign="center">
Custom mail server settings
</Typography>
@ -255,7 +278,7 @@ const AdvancedLoginMenu: FC = () => {
</Typography>
<br />
<Grid container spacing={2}>
{(["incoming", "outgoing"] as ServerType[]).map((type) => (
{serverTypes.map((type) => (
<ServerPropertiesColumn key={type} type={type} />
))}
</Grid>
@ -263,9 +286,9 @@ const AdvancedLoginMenu: FC = () => {
<br />
{error &&
(error.type == UserError.Timeout ||
error.type == UserError.Misc ||
error.type == UserError.Network) && (
(error.code == GatewayError.Timeout ||
error.code == GatewayError.Misc ||
error.code == GatewayError.Network) && (
<>
<Alert sx={{ textAlign: "left" }} severity="error">
<AlertTitle>Error</AlertTitle>
@ -276,9 +299,9 @@ const AdvancedLoginMenu: FC = () => {
)}
<Button
disabled={missingFields}
type="submit"
disabled={missingFields || fetching}
fullWidth
type="submit"
variant="contained"
>
Login
@ -316,7 +339,7 @@ const LoginForm: FC = () => {
// Reject the form if there any fields empty
if (missingFields) {
setError({ message: "Missing required fields", type: UserError.Misc });
setError({ message: "Missing required fields", code: GatewayError.Misc });
return;
}
@ -327,49 +350,50 @@ const LoginForm: FC = () => {
};
return (
<form onSubmit={onSubmit}>
<Stack direction="column" spacing={2}>
<img
style={{ width: theme.spacing(15), margin: "auto" }}
src="/android-chrome-512x512.png"
alt="logo"
/>
<Typography variant="h2">{import.meta.env.VITE_APP_NAME}</Typography>
<Typography variant="h5">{description}</Typography>
<Credentials
error={error}
identifier="default"
setError={setError}
setPassword={setPassword}
setUsername={setUsername}
/>
<Button
fullWidth
disabled={fetching || missingFields}
type="submit"
variant="contained"
>
Login
</Button>
{error &&
(error.type == UserError.Timeout ||
error.type == UserError.Misc ||
error.type == UserError.Network) && (
<Alert sx={{ textAlign: "left" }} severity="error">
<AlertTitle>Error</AlertTitle>
{error.message}
</Alert>
)}
<OtherLogins />
<AdvancedLoginMenu />
</Stack>
</form>
<Stack direction="column" spacing={2}>
<form onSubmit={onSubmit}>
<Stack direction="column" spacing={2}>
<img
style={{ width: theme.spacing(15), margin: "auto" }}
src="/android-chrome-512x512.png"
alt="logo"
/>
<Typography variant="h2">{import.meta.env.VITE_APP_NAME}</Typography>
<Typography variant="h5">{description}</Typography>
<Credentials
error={error}
identifier="default"
setError={setError}
setPassword={setPassword}
setUsername={setUsername}
/>
<Button
fullWidth
disabled={fetching || missingFields}
type="submit"
variant="contained"
>
Login
</Button>
{error &&
(error.code == GatewayError.Timeout ||
error.code == GatewayError.Misc ||
error.code == GatewayError.Network) && (
<Alert sx={{ textAlign: "left" }} severity="error">
<AlertTitle>Error</AlertTitle>
{error.message}
</Alert>
)}
<OtherLogins />
</Stack>
</form>
<AdvancedLoginMenu />
</Stack>
);
};

@ -54,10 +54,6 @@ const ActionBar: FC<{
const [search, setSearch] = useState<string>("");
const [messageListWidth] = useLocalStorageState<number>("messageListWidth", {
defaultValue: 400
});
const handleSubmit = (e: FormEvent): void => {
e.preventDefault();
@ -76,7 +72,7 @@ const ActionBar: FC<{
sx={{
backgroundColor: theme.palette.background.default,
borderBottom: `${theme.palette.divider} 1px solid`,
width: `${messageListWidth}px`,
width: 1,
p: 2
}}
direction="row"

@ -153,7 +153,10 @@ const UnMemoizedMessageOverview: FC = () => {
const [token] = useLocalStorageState<LocalToken>("accessToken");
const { data, isFetching } = useQuery<FullIncomingMessage | undefined>(
const { data, isFetching, error } = useQuery<
FullIncomingMessage | undefined,
string
>(
[
"message",
selectedMessage,
@ -188,94 +191,103 @@ const UnMemoizedMessageOverview: FC = () => {
}}
>
<Stack direction="row">
{data && (
<Stack direction="column" sx={{ flex: 1 }} spacing={2}>
<Box>
<Typography variant="h5">
{isFetching || !data ? (
<Skeleton />
) : (
data.subject ?? "(No subject)"
)}
</Typography>
<Typography
variant="subtitle1"
color={theme.palette.text.secondary}
<>
{error && (
<Stack direction="column" sx={{ flex: 1 }} spacing={2}>
{error}
</Stack>
)}
{data && !error && (
<Stack direction="column" sx={{ flex: 1 }} spacing={2}>
<Box>
<Typography variant="h5">
{isFetching || !data ? (
<Skeleton />
) : (
data.subject ?? "(No subject)"
)}
</Typography>
<Typography
variant="subtitle1"
color={theme.palette.text.secondary}
>
{isFetching || !data ? (
<Skeleton />
) : (
`${new Date(
data.date
).toLocaleDateString()} - ${new Date(
data.date
).toLocaleTimeString()}`
)}
</Typography>
</Box>
{data?.from && data?.from.length != 0 && (
<AddressList data={data.from} prefixText="From:" />
)}
{data?.to && data?.to.length != 0 && (
<AddressList data={data.to} prefixText="To:" />
)}
{data?.cc && data?.cc.length != 0 && (
<AddressList data={data.cc} prefixText="CC:" />
)}
{data?.bcc && data?.bcc.length != 0 && (