Compare commits

...

33 Commits

Author SHA1 Message Date
grimhilt
adb7a1161a add implementation of colors for address in front 2023-07-18 16:16:40 +02:00
grimhilt
3c069ed2f8 better title in message view modal 2023-07-16 16:46:38 +02:00
grimhilt
ae73326820 improve add account flow 2023-07-16 16:24:30 +02:00
grimhilt
f40b6758de add members in message modal 2023-07-16 15:54:57 +02:00
grimhilt
43e8cc7d3e correct typo 2023-07-15 00:27:58 +02:00
grimhilt
edddf9afbf add link to the thread of a message 2023-07-15 00:19:39 +02:00
grimhilt
3bffd88108 add original message in thread 2023-07-14 19:05:37 +02:00
grimhilt
0094783a4e minor changes 2023-07-14 16:26:13 +02:00
grimhilt
f42d819e45 confirmation modal 2023-05-23 16:35:23 +02:00
grimhilt
467b0eebe9 setup accounts 2023-05-23 15:56:27 +02:00
grimhilt
e8e8555c2f add smtp port on front 2023-05-23 15:36:14 +02:00
grimhilt
af4cc2f6a0 create custom component 2023-05-23 15:19:52 +02:00
grimhilt
2cae8f12a7 add route room delete 2023-05-17 18:14:25 +02:00
grimhilt
7be2e84691 add actions header room 2023-05-17 18:00:15 +02:00
grimhilt
737a22e1f8 improve svgloader 2023-05-17 17:44:55 +02:00
grimhilt
4ecd723cec load message in page loading 2023-05-08 01:10:13 +02:00
grimhilt
53c79aebc4 fix deletion back 2023-05-08 00:35:18 +02:00
grimhilt
b2b0949353 delete thread front 2023-05-08 00:34:32 +02:00
grimhilt
ffcfc57bbe fix errors and beautify with use of class 2023-05-07 23:42:44 +02:00
grimhilt
f7c95b3a36 update delete behavior when have thread and beautify code 2023-05-07 23:27:08 +02:00
grimhilt
843659b495 delete room when empty 2023-05-06 15:58:33 +02:00
grimhilt
1a7828b281 minor fixes for deletion compatibility 2023-05-06 15:17:02 +02:00
grimhilt
b821c89e20 delete from server working 2023-05-06 14:57:55 +02:00
grimhilt
c3374a612e compact mode for messages 2023-05-06 14:14:32 +02:00
grimhilt
686e6a4911 set loading of deletions 2023-05-06 13:30:50 +02:00
grimhilt
b137263bef deletion of messages (failing on server) 2023-05-06 13:23:13 +02:00
grimhilt
2c7b4f1c78 fix live sync mail 2023-05-06 12:31:13 +02:00
grimhilt
3dab9c8db1 try using search with syncManager 2023-05-06 12:28:53 +02:00
grimhilt
5aef5ab7b0 improve initial sync behavior 2023-05-05 19:09:27 +02:00
grimhilt
8f980748b5 link api front 2023-05-02 13:51:13 +02:00
grimhilt
22fb12e6d6 change full archi api backend 2023-05-02 13:47:04 +02:00
grimhilt
12e508c7cb update backend api architecture 2023-05-02 12:25:50 +02:00
grimhilt
29b4b7bfeb fix types highestmodseq 2023-05-02 12:12:09 +02:00
57 changed files with 1382 additions and 669 deletions

1
.gitignore vendored
View File

@@ -32,3 +32,4 @@ tmp
test.*
*.png
!*/schemas/*
todo

View File

@@ -1,18 +1,21 @@
# TRIFORM
*WORK IN PROGRESS*
_WORK IN PROGRESS_
TRIFORM (Threaded Rooms Interface For Organised Relational Mails) is a mail client which sorts mails into conversations based on the members present in each mails.
## Features
- [x] Multi account
- [x] Multi account (tested only with gmail)
- [x] Live syncing of mails
- [x] Live syncing of flags
- [x] Mark as read/unread
- [x] Flag important
- [ ] Attachments
- [ ] Send new mails
- [ ] Respond to mails
- [ ] Delete mails
- [x] Delete mails
- [ ] Delete rooms
- [ ] Live sync with the ui
## Installation

View File

@@ -1,6 +1,7 @@
import { Response } from "express";
import { getAccounts, registerAccount } from "../db/api-db";
import { getAccounts, getRooms, registerAccount } from "../db/api-db";
import { getAddressId } from "../db/utils/mail";
import logger from "../system/Logger";
import statusCodes from "../utils/statusCodes";
export default class Account {
@@ -11,10 +12,10 @@ export default class Account {
}
static async register(body, res: Response) {
const { email, pwd, xoauth, xoauth2, host, port, tls } = body;
const { email, pwd, xoauth, xoauth2, imapHost, smtpHost, imapPort, smtpPort, tls } = body;
getAddressId(email).then((addressId) => {
registerAccount(addressId, pwd, xoauth, xoauth2, host, port, tls)
.then((mailboxId) => {
registerAccount(addressId, pwd, xoauth, xoauth2, imapHost, smtpHost, imapPort, smtpPort, tls)
.then((mailboxId) => {0
res.status(statusCodes.OK).json({ id: mailboxId });
})
.catch(() => {
@@ -23,4 +24,16 @@ export default class Account {
});
// todo change mailbox to account
}
static async getRooms(body, res: Response) {
const { mailboxId, offset, limit } = body;
getRooms(mailboxId)
.then((rooms) => {
res.status(statusCodes.OK).json(rooms);
})
.catch((err) => {
logger.err(err);
res.status(statusCodes.INTERNAL_SERVER_ERROR);
});
}
}

View File

@@ -1,54 +1,120 @@
import statusCode from "../utils/statusCodes";
import { Response } from "express";
import { getMessageUid, getUserOfMailbox } from "../db/utils/mail";
import emailManager from "../mails/EmailManager";
import logger from "../system/Logger";
import Message from "../mails/message/Message";
import Room from "../mails/room/Room";
export default class Message {
static async addFlag(body, res: Response) {
export default class MessageAbl {
static async changeFlag(body, res: Response, isDelete: boolean) {
const { mailboxId, messageId, flag } = body;
const uid = (await getMessageUid(messageId))[0]?.uid;
if (!uid) {
const message = new Message().setMessageId(messageId);
try {
await message.useUid();
} catch (err) {
res.status(statusCode.NOT_FOUND).send({ error: "Message uid not found." });
}
const user = (await getUserOfMailbox(mailboxId))[0]?.user;
if (!user) {
try {
await message.useMailbox(mailboxId);
} catch (err) {
res.status(statusCode.NOT_FOUND).send({ error: "Not account for this mailbox." });
}
emailManager
.getImap(user)
.getMailbox(mailboxId)
.addFlag(uid.toString(), [flag])
.then(() => {
res.status(statusCode.OK).send();
})
.catch((err) => {
console.log(err);
try {
if (isDelete) {
await message.mailbox.removeFlag(message.uid.toString(), flag);
} else {
await message.mailbox.addFlag(message.uid.toString(), flag);
}
} catch (err) {
res.status(statusCode.METHOD_FAILURE).send({ error: err });
});
}
res.status(statusCode.OK).send();
}
static async addFlag(body, res: Response) {
await MessageAbl.changeFlag(body, res, false);
}
static async removeFlag(body, res: Response) {
const { mailboxId, messageId, flag } = body;
const uid = (await getMessageUid(messageId))[0]?.uid;
if (!uid) {
res.status(statusCode.NOT_FOUND).send({ error: "Message uid not found." });
await MessageAbl.changeFlag(body, res, true);
}
const user = (await getUserOfMailbox(mailboxId))[0]?.user;
if (!user) {
static async deleteRemoteUtil(message: Message, mailboxId: number, res, isFull: boolean): Promise<boolean> {
try {
await message.useUid();
} catch (error) {
logger.err(error);
res.status(statusCode.NOT_FOUND).send({ error: "Message uid not found." });
return false;
}
try {
await message.useMailbox(mailboxId);
} catch (error) {
res.status(statusCode.NOT_FOUND).send({ error: "Not account for this mailbox." });
return false;
}
emailManager
.getImap(user)
.getMailbox(mailboxId)
.removeFlag(uid.toString(), [flag])
.then(() => {
res.status(statusCode.OK).send();
})
.catch((err) => {
console.log(err);
res.status(statusCode.METHOD_FAILURE).send({ error: err });
try {
await message.mailbox.addFlag(message.uid.toString(), ["\\Deleted"]);
await message.mailbox.moveToTrash(message.uid.toString(), (err) => {
if (err) {
logger.err(err);
}
// throw err;
});
} catch (err) {
logger.log(err);
res.status(statusCode.METHOD_FAILURE).send({ error: err });
return false;
}
if (isFull) {
res.status(statusCode.OK).send();
}
return true;
}
static deleteRemoteOnly = async (body, res: Response) => {
const { mailboxId, messageId } = body;
const message = new Message().setMessageId(messageId);
await MessageAbl.deleteRemoteUtil(message, mailboxId, res, true);
};
static deleteEverywhere = async (body, res: Response) => {
const { mailboxId, messageId } = body;
const message = new Message().setMessageId(messageId);
try {
await message.useFlags();
} catch (err) {
res.status(statusCode.METHOD_FAILURE).send({ error: err });
logger.err(err);
return;
}
// if message not deleted remotly, delete it
if (!message.isDeleted) {
const success = await MessageAbl.deleteRemoteUtil(message, mailboxId, res, false);
if (!success) {
return;
}
}
const room = await new Room().setRoomIdOnMessageId(messageId);
try {
await message.delete();
if (room.roomId && await room.shouldDelete()) {
await room.delete();
res.status(statusCode.OK).json({ deleteRoom: true }).send();
return;
}
} catch (err) {
res.status(statusCode.METHOD_FAILURE).send({ error: err });
return;
}
res.status(statusCode.OK).send();
};
}

View File

@@ -6,6 +6,9 @@ import { getLastMsgData, getRoomOwner } from "../db/Room-db";
import emailManager from "../mails/EmailManager";
import MailBuilder from "../mails/utils/mailBuilder";
import { getAddresses } from "../db/utils/mail";
import { getMembers, getMessages, getRooms } from "../db/api-db";
import logger from "../system/Logger";
import Room from "../mails/room/Room";
function rmUserFromAddrs(addresses: { email: string }[], user: string) {
let index = addresses.findIndex((a) => a.email == user);
@@ -13,7 +16,7 @@ function rmUserFromAddrs(addresses: { email: string }[], user: string) {
addresses.splice(index, 1);
}
}
export default class Room {
export default class RoomAbl {
// todo change name of reponse
static async response(body, res: Response) {
const { user, roomId, text, html } = body;
@@ -25,12 +28,10 @@ export default class Room {
const mailBuilder = new MailBuilder();
mailBuilder.from(user).to(ownerEmail).text(text).html(html);
emailManager.getSmtp(user).sendMail(mailBuilder.message);
emailManager.getSmtp(user)?.sendMail(mailBuilder.message);
res.status(statusCode.OK).send();
} else if (roomType === RoomType.GROUP || roomType === RoomType.THREAD) {
const lastMsgData = (await getLastMsgData(roomId))[0];
console.log(lastMsgData);
const mailBuilder = new MailBuilder();
mailBuilder.inReplySubject(lastMsgData.subject).inReplyTo(lastMsgData.messageID).text(text).html(html);
@@ -52,10 +53,41 @@ export default class Room {
.to(to.map((a) => a.email))
.cc(cc.map((a) => a.email));
emailManager.getSmtp(user).sendMail(mailBuilder.message);
emailManager.getSmtp(user)?.sendMail(mailBuilder.message);
res.status(statusCode.OK).send();
} else {
res.status(statusCode.FORBIDDEN).send({ error: "Cannot add a new message in a room or a channel." });
}
}
static async getMessages(body, res: Response) {
const { roomId } = body;
getMessages(roomId)
.then((messages) => {
res.status(statusCode.OK).json(messages);
})
.catch((err) => {
logger.err(err);
res.status(statusCode.INTERNAL_SERVER_ERROR);
});
}
static async getMembers(body, res: Response) {
const { roomId } = body;
getMembers(roomId)
.then((addresses) => {
res.status(statusCode.OK).json(addresses);
})
.catch((err) => {
logger.err(err);
res.status(statusCode.INTERNAL_SERVER_ERROR);
});
}
static async delete(body, res: Response) {
const { roomId } = body;
console.log("delete", roomId);
const room = new Room().setRoomId(roomId);
// todo
}
}

View File

@@ -1,13 +0,0 @@
import statusCode from "../utils/statusCodes";
import { getMembers } from "../db/api-db";
import logger from "../system/Logger";
export async function members(body, res) {
const { roomId } = body;
getMembers(roomId).then((addresses) => {
res.status(statusCode.OK).json(addresses);
}).catch((err) => {
logger.err(err)
res.status(statusCode.INTERNAL_SERVER_ERROR);
});
}

View File

@@ -1,16 +0,0 @@
import statusCode from "../utils/statusCodes";
import { getMessages } from "../db/api-db";
import logger from "../system/Logger";
import { Response } from "express";
export async function messages(body, res: Response) {
const { roomId } = body;
getMessages(roomId)
.then((messages) => {
res.status(statusCode.OK).json(messages);
})
.catch((err) => {
logger.err(err);
res.status(statusCode.INTERNAL_SERVER_ERROR);
});
}

View File

@@ -1,14 +0,0 @@
import statusCode from "../utils/statusCodes";
import { getRooms } from "../db/api-db";
import logger from "../system/Logger";
import { Response } from "express";
export async function rooms(body, res: Response) {
const { mailboxId, offset, limit } = body;
getRooms(mailboxId).then((rooms) => {
res.status(statusCode.OK).json(rooms);
}).catch((err) => {
logger.err(err)
res.status(statusCode.INTERNAL_SERVER_ERROR);
});
}

View File

@@ -12,8 +12,10 @@ export async function getRoomOwner(roomId: number) {
return await execQueryAsync(query, values);
}
/**
* get all the data needed to reply to a message in a room
*/
export async function getLastMsgData(roomId: number) {
const query = `
SELECT
msg.message_id AS id,
@@ -59,3 +61,25 @@ export async function getLastMsgData(roomId: number) {
const values = [roomId];
return await execQueryAsync(query, values);
}
export async function getRoomOnMessageId(messageId: number) {
const query = `SELECT room_id FROM app_room_message WHERE message_id = ?`;
const values = [messageId];
return await execQueryAsync(query, values);
}
export async function getRoomNbMessageAndThread(roomId: number): Promise<{ nbMessage: number; nbThread: number }[]> {
const query = `
SELECT COUNT(arm.room_id) AS nbMessage, COUNT(app_thread.room_id) AS nbThread
FROM app_room_message arm
INNER JOIN app_thread ON (app_thread.root_id = arm.room_id OR app_thread.parent_id = arm.room_id)
WHERE arm.room_id = ?`;
const values = [roomId];
return await execQueryAsync(query, values);
}
export async function deleteRoom(roomId: number) {
const query = `DELETE FROM app_room WHERE room_id = ?`;
const values = [roomId];
return await execQueryAsync(query, values);
}

View File

@@ -1,12 +1,12 @@
import { execQueryAsync, execQueryAsyncWithId } from "./db";
import { queryCcId, queryToId, queryFromId } from "./utils/addressQueries";
export async function registerAccount(userId, pwd, xoauth, xoauth2, host, port, tls) {
export async function registerAccount(userId, pwd, xoauth, xoauth2, imapHost, smtpHost, imapPort, smtpPort, tls) {
const query = `
INSERT INTO app_account
(user_id, account_pwd, xoauth, xoauth2, host, port, tls) VALUES (?, ?, ?, ?, ?, ?, ?)
(user_id, account_pwd, xoauth, xoauth2, imap_host, smtp_host, imap_port, smtp_port, tls) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const values = [userId, pwd, xoauth, xoauth2, host, port, tls];
const values = [userId, pwd, xoauth, xoauth2, imapHost, smtpHost, imapPort, smtpPort, tls];
return await execQueryAsyncWithId(query, values);
}
@@ -38,6 +38,7 @@ export async function getRooms(mailboxId: number) {
room.room_type AS roomType,
mailbox_message.mailbox_id AS mailboxId,
app_thread.parent_id
FROM app_room room
INNER JOIN message ON message.message_id = room.message_id
INNER JOIN mailbox_message ON mailbox_message.message_id = message.message_id
@@ -55,6 +56,7 @@ export async function getRooms(mailboxId: number) {
)
) notSeenRoom ON notSeenRoom.room_id = room.room_id
-- get not seen in thread
LEFT JOIN (
SELECT app_room_message.message_id, app_thread.parent_id
FROM app_room
@@ -89,7 +91,8 @@ export async function getMessages(roomId: number) {
subjectT.value AS subject,
content.text AS content,
message.idate AS date,
GROUP_CONCAT(flagT.flag_name) AS flags
GROUP_CONCAT(flagT.flag_name) AS flags,
thread.room_id AS thread
FROM app_room_message msg
${queryFromId} fromT ON msg.message_id = fromT.message_id
@@ -106,7 +109,8 @@ export async function getMessages(roomId: number) {
) subjectT ON msg.message_id = subjectT.message_id
LEFT JOIN (
SELECT bodypart.text, header_field.message_id FROM bodypart
SELECT bodypart.text, header_field.message_id
FROM bodypart
INNER JOIN header_field
INNER JOIN field_name
WHERE
@@ -120,6 +124,13 @@ export async function getMessages(roomId: number) {
INNER JOIN message ON message.message_id = msg.message_id
-- get room_id of thread room with this message as origin
LEFT JOIN app_room thread ON (
thread.message_id = msg.message_id AND
msg.room_id != thread.room_id AND
thread.room_type = 4
)
WHERE msg.room_id = ?
GROUP BY msg.message_id
ORDER BY message.idate ASC;

View File

@@ -18,8 +18,10 @@ CREATE TABLE app_account (
account_pwd BINARY(22),
xoauth VARCHAR(116),
xoauth2 VARCHAR(116),
host VARCHAR(255) NOT NULL DEFAULT 'localhost',
port INT(5) NOT NULL DEFAULT 143,
imap_host VARCHAR(255) NOT NULL DEFAULT 'localhost',
imap_port INT(5) NOT NULL DEFAULT 993,
smtp_host VARCHAR(255) NOT NULL DEFAULT 'localhost',
smtp_port INT(5) NOT NULL DEFAULT 465,
tls BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY (account_id),
FOREIGN KEY (user_id) REFERENCES address(address_id) ON DELETE CASCADE
@@ -121,13 +123,13 @@ CREATE TABLE app_room (
room_id INT AUTO_INCREMENT,
room_name VARCHAR(255) NOT NULL,
owner_id INT NOT NULL,
message_id INT NOT NULL,
message_id INT,
room_type INT NOT NULL DEFAULT 0,
lastUpdate TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(),
PRIMARY KEY (room_id),
UNIQUE KEY (owner_id, message_id, room_type),
FOREIGN KEY (owner_id) REFERENCES address(address_id),
FOREIGN KEY (message_id) REFERENCES message(message_id)
FOREIGN KEY (message_id) REFERENCES message(message_id) ON DELETE SET NULL
);
-- 12
@@ -175,6 +177,5 @@ create table flag (
flag_id INT NOT NULL,
UNIQUE KEY (message_id, flag_id),
FOREIGN KEY (message_id) REFERENCES message(message_id) ON DELETE CASCADE,
FOREIGN KEY (message_id) REFERENCES message(message_id) ON DELETE CASCADE,
FOREIGN KEY (flag_id) REFERENCES flag_name(flag_id) ON DELETE CASCADE
);

View File

@@ -6,9 +6,11 @@ export async function getAllAccounts() {
app_account.account_id AS id,
address.email AS user,
app_account.account_pwd AS password,
app_account.host AS host,
app_account.port AS port,
app_account.tls AS tls
app_account.imap_host,
app_account.imap_port,
app_account.smtp_host,
app_account.smtp_port,
app_account.tls
FROM app_account INNER JOIN address
WHERE address.address_id = app_account.user_id
`;

View File

@@ -0,0 +1,23 @@
import { execQueryAsync } from "../db";
export async function getFlagsOnUid(uid: number): Promise<{ flag_id: number; flag_name: string }[]> {
const query = `
SELECT flag_name FROM flag_name
INNER JOIN flag ON flag.flag_id = flag_name.flag_id
INNER JOIN mailbox_message ON mailbox_message.message_id = flag.message_id
WHERE mailbox_message.uid = ?
`;
const values = [uid];
return await execQueryAsync(query, values);
}
export async function getFlagsOnId(messageId: number): Promise<{ flag_id: number; flag_name: string }[]> {
const query = `
SELECT flag_name FROM flag_name
INNER JOIN flag ON flag.flag_id = flag_name.flag_id
INNER JOIN mailbox_message ON mailbox_message.message_id = flag.message_id
WHERE mailbox_message.message_id = ?
`;
const values = [messageId];
return await execQueryAsync(query, values);
}

View File

@@ -1,6 +1,6 @@
import { execQuery, execQueryAsync, execQueryAsyncWithId } from "../db";
export async function getFlags(uid: number): Promise<{flag_id: number, flag_name: string}[]> {
export async function getFlags(uid: number): Promise<{ flag_id: number; flag_name: string }[]> {
const query = `
SELECT * FROM flag_name
INNER JOIN flag ON flag.flag_id = flag_name.flag_id
@@ -19,12 +19,18 @@ export async function deleteFlag(messageId: number, flagId: number) {
export async function updateMailboxSeen(messageId: number, isSeen: boolean) {
const query = `UPDATE mailbox_message SET seen = ? WHERE message_id = ?`;
const values = [messageId, isSeen];
const values = [isSeen, messageId];
return await execQueryAsync(query, values);
}
export async function updateMailboxDeleted(messageId: number, isDeleted: boolean) {
const query = `UPDATE mailbox_message SET deleted = ? WHERE message_id = ?`;
const values = [messageId, isDeleted];
const values = [isDeleted, messageId];
return await execQueryAsync(query, values);
}
export async function deleteMessage(messageId: number) {
const query = `DELETE FROM message WHERE message_id = ?`;
const values = [messageId];
return await execQueryAsync(query, values);
}

View File

@@ -40,6 +40,12 @@ export async function getMessageUid(messageId: number): Promise<{uid: number}[]>
return await execQueryAsync(query, values);
}
export async function getMessageIdOnID(messageID: string): Promise<{message_id: number}[]> {
const query = `SELECT message_id FROM message WHERE messageID = ?`;
const values = [messageID];
return await execQueryAsync(query, values);
}
export async function findRoomByOwner(ownerId: number): Promise<{ room_id: number }[]> {
const query = `SELECT room_id FROM app_room WHERE owner_id = ?`;
const values = [ownerId];

View File

@@ -14,9 +14,9 @@ export class ImapInstance {
this.imap = new Imap({
user: account.user,
password: account.password,
tlsOptions: { servername: account.host },
host: account.host,
port: account.port,
tlsOptions: { servername: account.imap_host },
host: account.imap_host,
port: account.imap_port,
tls: account.tls,
});
this.account = account;
@@ -41,38 +41,45 @@ export class ImapInstance {
this.imap.connect();
}
imapReady() {
imapReady = () => {
getAllMailboxes(this.account.id).then((mailboxes) => {
if (mailboxes.length > 0) {
this.boxes.push(new Mailbox(this.imap, mailboxes[0].mailbox_id, mailboxes[0].mailbox_name));
this.boxes.push(new Mailbox(mailboxes[0].mailbox_id, mailboxes[0].mailbox_name, this));
} else {
this.imap.getBoxes("", (err, boxes) => {
if (err) logger.err(err);
const allBoxName = this.getAllBox(boxes);
this.getMailboxName("All").then((allBoxName) => {
registerMailbox(this.account.id, allBoxName).then((mailboxId) => {
this.boxes.push(new Mailbox(this.imap, mailboxId, allBoxName));
this.boxes.push(new Mailbox(mailboxId, allBoxName, this));
});
});
}
});
}
};
getAllBox(boxes) {
// ideally we should get the all box to get all messages
let allBox = "";
getMailboxName(boxToFound: string): Promise<string> {
return new Promise((resolve, rejects) => {
let matchBox = "";
this.imap.getBoxes("", (err, boxes) => {
Object.keys(boxes).forEach((key) => {
if (key === "INBOX") return;
if (allBox.includes("/")) return; // already found
if (matchBox.includes("/")) return; // already found
if (!boxes[key].children) return; // no children
allBox = key;
Object.keys(boxes[key].children).forEach((childBoxes) => {
if (boxes[key].children[childBoxes].attribs.includes("\\All")) {
allBox += "/" + childBoxes;
matchBox = key;
Object.keys(boxes[key].children).forEach((childBox) => {
let attribs = boxes[key].children[childBox].attribs;
for (let i = 0; i < attribs.length; i++) {
if (attribs[i].includes(boxToFound)) {
matchBox += "/" + childBox;
}
}
});
});
if (!matchBox.includes("/")) {
logger.warn(`Did not find "${boxToFound}" mailbox`);
rejects();
} else {
resolve(matchBox);
}
});
});
if (!allBox.includes("/")) logger.warn("Did not find 'All' mailbox");
return allBox;
}
getMailbox(mailboxId: number): Mailbox {

View File

@@ -1,10 +1,12 @@
import Imap, { ImapMessageAttributes, Box } from "imap";
import Imap, { Box } from "imap";
import { resolve } from "path";
import { getMailbox, getMailboxModseq, updateMailbox, updateMailboxModseq } from "../../db/imap/imap-db";
import { Attrs, AttrsWithEnvelope } from "../../interfaces/mail/attrs.interface";
import logger from "../../system/Logger";
import RegisterMessageInApp from "../message/saveMessage";
import { saveMessage } from "../message/storeMessage";
import updateMessage from "../message/updateMessage";
import { ImapInstance } from "./ImapInstance";
export interface ImapInfo {
uid: number;
@@ -19,14 +21,16 @@ export default class Mailbox {
box: Box;
msgToSync: number;
syncing: boolean;
imapInstance: ImapInstance;
constructor(_imap: Imap, _boxId: number, _boxName: string) {
this.imap = _imap;
constructor(_boxId: number, _boxName: string, _imapInstance: ImapInstance) {
this.imap = _imapInstance.imap;
this.boxName = _boxName;
this.id = _boxId;
this.box;
this.msgToSync = 0;
this.syncing = false;
this.imapInstance = _imapInstance;
this.init();
}
@@ -44,7 +48,7 @@ export default class Mailbox {
this.imap.on("mail", (numNewMsgs: number) => {
if (!this.syncing) {
// if not syncing restart a sync
this.syncMail(this.box.uidnext, this.box.uidnext + numNewMsgs);
this.syncManager(this.box.uidnext - 1, this.box.uidnext + numNewMsgs - 1);
} else {
// else save number of message to sync latter
this.msgToSync += numNewMsgs;
@@ -57,26 +61,32 @@ export default class Mailbox {
const updateMsg = new updateMessage(info.uid, info.flags);
updateMsg.updateFlags();
});
// wait for deletion
this.imap.on("expunge", (seqno: number) => {
// const updateMsg = new updateMessage(info.)
console.log("Message with sequence number " + seqno + " has been deleted from the server.");
});
});
}
async updateModseq(newModseq: number) {
updateMailboxModseq(this.id, newModseq).then(() => {
this.box.highestmodseq = newModseq;
this.box.highestmodseq = newModseq.toString();
});
}
async initSync(box: Box) {
// sync mail only if has new messages
if (this.box.uidnext < box.uidnext) {
this.syncMail(this.box.uidnext, box.uidnext);
this.syncManager(this.box.uidnext, box.uidnext);
} else {
logger.log("Mail already up to date");
}
// sync flags
const lastModseq = (await getMailboxModseq(this.id))[0]?.modseq ?? 0;
if (box.highestmodseq > lastModseq) {
if (parseInt(box.highestmodseq) > lastModseq) {
const fetchStream = this.imap.fetch("1:*", { bodies: "", modifiers: { changedsince: lastModseq } });
fetchStream.on("message", (message) => {
message.once("attributes", (attrs) => {
@@ -91,53 +101,44 @@ export default class Mailbox {
logger.log("Done fetching new flags");
});
} else {
logger.log("Flags already up to date")
logger.log("Flags already up to date");
}
this.updateModseq(box.highestmodseq);
this.updateModseq(parseInt(box.highestmodseq));
}
async syncMail(savedUid: number, currentUid: number) {
syncManager = async (savedUid: number, currentUid: number) => {
this.syncing = true;
const promises: Promise<unknown>[] = [];
const mails: Attrs[] = [];
logger.log(`Syncing from ${savedUid} to ${currentUid} uid`);
const f = this.imap.seq.fetch(`${savedUid}:${currentUid}`, {
size: true,
envelope: true,
});
logger.log(`Fetching from ${savedUid} to ${currentUid} uid`);
const nbMessageToSync = currentUid - savedUid;
let STEP = nbMessageToSync > 200 ? Math.floor(nbMessageToSync / 7) : nbMessageToSync;
let mails: AttrsWithEnvelope[] = [];
f.on("message", (msg, seqno) => {
msg.once("attributes", (attrs: AttrsWithEnvelope) => {
mails.push(attrs);
promises.push(saveMessage(attrs, this.id, this.imap));
});
});
f.once("error", (err) => {
logger.err("Fetch error: " + err);
});
f.once("end", async () => {
let step = 20;
for (let i = 0; i < promises.length; i += step) {
for (let j = i; j < (i + step && promises.length); j++) {
await new Promise((resolve, reject) => {
promises[j]
.then(async (res: number) => {
const register = new RegisterMessageInApp(res, mails[j], this.id);
for (let i = 0; i < nbMessageToSync; i += STEP) {
mails = [];
try {
// fetch mails
let secondUid = savedUid + STEP < currentUid ? savedUid + STEP : currentUid;
await this.mailFetcher(savedUid, secondUid, mails);
logger.log(`Fetched ${STEP} uids (${mails.length} messages)`);
// save same in the database
for (let k = 0; k < mails.length; k++) {
try {
const messageId = await saveMessage(mails[k], this.id, this.imap);
const register = new RegisterMessageInApp(messageId, mails[k], this.id);
await register.save();
resolve("");
})
.catch((err) => {
reject(err);
});
});
} catch (error) {
logger.err("Failed to save a message: " + error);
}
logger.log(`Saved messages ${i + step > promises.length ? promises.length : i + step}/${mails.length}`);
updateMailbox(this.id, mails[i].uid);
}
updateMailbox(this.id, currentUid);
this.syncing = false;
savedUid = secondUid;
this.box.uidnext += savedUid;
updateMailbox(this.id, savedUid);
} catch (error) {
logger.err("Failed to sync message " + error);
}
logger.log(`Saved messages in uids ${i + STEP > nbMessageToSync ? nbMessageToSync : i + STEP}/${nbMessageToSync}`);
}
// if has receive new msg during last sync then start a new sync
if (this.msgToSync > 0) {
@@ -145,8 +146,33 @@ export default class Mailbox {
this.box.uidnext += this.msgToSync;
// reset value to allow to detect new incoming message while syncing
this.msgToSync = 0;
this.syncMail(currentUid, this.box.uidnext);
await this.syncManager(currentUid, this.box.uidnext);
}
this.syncing = false;
logger.log(`Finished syncing messages`);
};
async mailFetcher(startUid: number, endUid: number, mails: Attrs[]): Promise<any> {
return new Promise((resolve, reject) => {
const f = this.imap.fetch(`${startUid}:${endUid}`, {
size: true,
envelope: true,
});
f.on("message", (msg, seqno) => {
msg.once("attributes", (attrs: AttrsWithEnvelope) => {
mails.push(attrs);
});
});
f.once("error", (err) => {
logger.err("Fetch error when fetching in uid range: " + err);
reject(1);
});
f.once("end", async () => {
resolve(0);
});
});
}
@@ -173,4 +199,13 @@ export default class Mailbox {
});
});
}
move(source: string, mailboxName: string, callback: (error: Error) => void) {
this.imap.move(source, mailboxName, callback);
}
async moveToTrash(source: string, callback: (error: Error) => void) {
const trashName = await this.imapInstance.getMailboxName("Trash");
this.move(source, trashName, callback);
}
}

View File

@@ -0,0 +1,86 @@
import { getFlagsOnId, getFlagsOnUid } from "../../db/message/message-db";
import { deleteMessage } from "../../db/message/updateMessage-db";
import { getMessageUid, getUserOfMailbox } from "../../db/utils/mail";
import emailManager from "../EmailManager";
import Mailbox from "../imap/Mailbox";
import Room from "../room/Room";
export default class Message {
messageId: number;
uid: number;
private _flags: string[] | undefined;
private _mailbox: Mailbox;
constructor() {}
setMessageId(messageId: number): Message {
this.messageId = messageId;
return this;
}
async useUid(): Promise<Message> {
if (!this.messageId) {
throw "Define message id before trying to find uid";
}
this.uid = (await getMessageUid(this.messageId))[0]?.uid;
if (!this.uid) {
throw "Uid not found";
}
return this;
}
async useFlags(): Promise<Message> {
let flags;
if (!this._flags) {
if (this.messageId) {
flags = await getFlagsOnId(this.messageId);
} else if (this.uid) {
flags = await getFlagsOnUid(this.uid);
} else {
throw "Neither message id or uid are set, please do so before attempting to load flags";
}
this._flags = flags;
}
return this;
}
async useMailbox(mailboxId: number, user?: string): Promise<Message> {
if (!user) {
user = (await getUserOfMailbox(mailboxId))[0]?.user;
if (!user) {
throw "Cannot find user of this mailbox";
}
}
this._mailbox = emailManager.getImap(user).getMailbox(mailboxId);
return this;
}
get mailbox() {
if (!this._mailbox) {
throw "Call useMailbox before calling functions related to mailbox";
}
return this._mailbox;
}
get flags(): string[] {
console.log("called");
console.log(this._flags);
if (!this._flags) {
throw "Flags not loaded, call useFlags before calling functions related to flags";
} else {
return this._flags;
}
}
get isDeleted(): boolean {
return this.flags.includes("\\Deleted");
}
delete = async (messageId?: number): Promise<Message> => {
if (!(this.messageId ?? messageId)) {
throw "Delete need to have the message id";
}
await deleteMessage(this.messageId ?? messageId);
return this;
};
}

View File

@@ -11,7 +11,7 @@ import {
getThreadInfoOnId,
} from "../../db/message/saveMessage-db";
import { findRoomByOwner, getAddressId, getUserIdOfMailbox } from "../../db/utils/mail";
import { findRoomByOwner, getAddressId, getMessageIdOnID, getUserIdOfMailbox } from "../../db/utils/mail";
import { nbMembers } from "../utils/envelopeUtils";
import logger from "../../system/Logger";
import { Attrs, Envelope, User } from "../../interfaces/mail/attrs.interface";
@@ -55,93 +55,6 @@ export default class RegisterMessageInApp {
this.inReplyTo = "";
}
async init() {
if (this.envelope.from) {
this.ownerId = await getAddressId(createAddress(this.envelope.from[0])); // todo use sender or from ?
} else {
throw new Error("Envelope must have a 'from' field");
}
}
isDm = () => nbMembers(this.envelope) == 2;
async isFromUs() {
if (this.userId == -1) {
await getUserIdOfMailbox(this.boxId).then((res) => {
this.userId = res[0]?.user_id;
});
}
return this.ownerId == this.userId;
}
async registerMembers(roomId: number) {
getAllMembers(this.messageId).then((res) => {
if (res.lenght == 0) return;
const data = res[0].id.split(",");
data.forEach(async (memberId: number) => {
await registerMember(roomId, memberId);
});
});
}
async initiateRoom(owner: number, roomType: RoomType) {
try {
const roomId = await createRoom(this.envelope.subject, owner, this.messageId, roomType);
await registerMessageInRoom(this.messageId, roomId, this.envelope.date);
await this.registerMembers(roomId);
return roomId;
} catch (err) {
logger.err(err);
}
}
async createOrRegisterOnExistence(owner: number, roomType: RoomType) {
await findRoomByOwner(owner).then(async (res) => {
if (res.length == 0) {
// first message with this sender
await this.initiateRoom(owner, roomType);
} else {
// not a reply, add to the list of message if this sender
await registerMessageInRoom(this.messageId, res[0].room_id, this.envelope.date);
}
});
}
async initiateThread() {
await createRoom(this.envelope.subject, this.ownerId, this.messageId, RoomType.THREAD).then(
async (threadId: number) => {
// find parent room infos
let roomId: number;
let root_id: number;
await getThreadInfo(this.inReplyTo).then(async (room) => {
// todo room not lenght, reply to transfer ?
roomId = room[0].room_id;
root_id = room[0].root_id;
if (root_id === undefined) root_id = roomId;
await registerThread(threadId, roomId, root_id);
});
// impl register previous message or go back
await registerMessageInRoom(this.messageId, threadId, this.envelope.date);
await this.registerMembers(threadId);
},
);
}
async createOrRegisterOnMembers(roomId: number, isThread?: boolean) {
const hasSameMembers = await hasSameMembersAsParent(this.messageId, this.inReplyTo);
if (hasSameMembers) {
await registerMessageInRoom(this.messageId, roomId, this.envelope.date);
if (isThread) {
await getThreadInfoOnId(roomId).then(async (res) => {
let root_id = res[0].root_id;
if (root_id == undefined) root_id = res[0].room_id;
});
}
} else {
await this.initiateThread();
}
}
async save() {
await this.init();
if (this.envelope.inReplyTo) {
@@ -194,4 +107,98 @@ export default class RegisterMessageInApp {
}
});
}
async init() {
if (this.envelope.from) {
this.ownerId = await getAddressId(createAddress(this.envelope.from[0])); // todo use sender or from ?
} else {
throw new Error("Envelope must have a 'from' field");
}
}
isDm = () => nbMembers(this.envelope) == 2;
async isFromUs() {
if (this.userId == -1) {
await getUserIdOfMailbox(this.boxId).then((res) => {
this.userId = res[0]?.user_id;
});
}
return this.ownerId == this.userId;
}
/**
* add all members of the message to the room
*/
async registerMembers(roomId: number) {
getAllMembers(this.messageId).then((res) => {
if (res.lenght == 0) return;
const data = res[0].id.split(",");
data.forEach(async (memberId: number) => {
await registerMember(roomId, memberId);
});
});
}
async initiateRoom(owner: number, roomType: RoomType) {
try {
const roomId = await createRoom(this.envelope.subject, owner, this.messageId, roomType);
await registerMessageInRoom(this.messageId, roomId, this.envelope.date);
await this.registerMembers(roomId);
return roomId;
} catch (err) {
logger.err(err);
}
}
async createOrRegisterOnExistence(owner: number, roomType: RoomType) {
await findRoomByOwner(owner).then(async (res) => {
if (res.length == 0) {
// first message with this sender
await this.initiateRoom(owner, roomType);
} else {
// not a reply, add to the list of message if this sender
await registerMessageInRoom(this.messageId, res[0].room_id, this.envelope.date);
}
});
}
async initiateThread() {
const inReplyToId = (await getMessageIdOnID(this.inReplyTo))[0]?.message_id;
await createRoom(this.envelope.subject, this.ownerId, inReplyToId, RoomType.THREAD).then(
async (threadId: number) => {
// find parent room infos
let roomId: number;
let root_id: number;
await getThreadInfo(this.inReplyTo).then(async (room) => {
// todo room not lenght, reply to transfer ?
roomId = room[0].room_id;
root_id = room[0].root_id;
if (root_id === undefined) root_id = roomId;
await registerThread(threadId, roomId, root_id);
});
// add original message
await registerMessageInRoom(inReplyToId, threadId, this.envelope.date);
// add reply message
await registerMessageInRoom(this.messageId, threadId, this.envelope.date);
await this.registerMembers(threadId);
},
);
}
async createOrRegisterOnMembers(roomId: number, isThread?: boolean) {
const hasSameMembers = await hasSameMembersAsParent(this.messageId, this.inReplyTo);
if (hasSameMembers) {
await registerMessageInRoom(this.messageId, roomId, this.envelope.date);
if (isThread) {
await getThreadInfoOnId(roomId).then(async (res) => {
let root_id = res[0].root_id;
if (root_id == undefined) root_id = res[0].room_id;
});
}
} else {
await this.initiateThread();
}
}
}

46
back/mails/room/Room.ts Normal file
View File

@@ -0,0 +1,46 @@
import { deleteRoom, getRoomNbMessageAndThread, getRoomOnMessageId } from "../../db/Room-db";
export default class Room {
private _roomId: number;
constructor() {}
setRoomId(roomId: number): Room {
this._roomId = roomId;
return this;
}
get roomId(): number {
return this._roomId;
}
async setRoomIdOnMessageId(messageId: number): Promise<Room> {
const res = await getRoomOnMessageId(messageId);
if (res.length == 0) {
throw "Message has no room";
}
this._roomId = res[0].room_id;
return this;
}
// check if the room have threads or messages
async shouldDelete(): Promise<boolean> {
if (!this._roomId) {
throw "shouldDelete needs to have a roomId set.";
}
const res = await getRoomNbMessageAndThread(this._roomId);
if (res.length === 0) return true;
if (res[0].nbMessage === 0 && res[0].nbThread === 0) {
return true;
}
return false;
}
async delete(): Promise<Room> {
if (!this._roomId) {
throw "shouldDelete needs to have a roomId set.";
}
await deleteRoom(this._roomId);
return this;
}
}

View File

@@ -5,12 +5,11 @@ export class SmtpInstance {
transporter: Transporter;
user: string;
constructor(account: { user: string; password: string }) {
// todo store other data
constructor(account: { user: string; password: string, smtp_host: string, smtp_port: number }) {
this.user = account.user;
this.transporter = nodemailer.createTransport({
host: "smtp.gmail.com",
port: 465,
host: account.smtp_host,
port: account.smtp_port,
secure: true,
auth: {
user: account.user,

View File

@@ -49,4 +49,5 @@ export default class MailBuilder {
this.message.subject = "RE: " + originSubject;
return this;
}
// https://cr.yp.to/immhf/thread.html
}

28
back/routes/account.ts Normal file
View File

@@ -0,0 +1,28 @@
import express from "express";
import Account from "../abl/Account-abl";
import validator from "../validator/validator";
const router = express.Router();
/**
* Return all mailboxes and folders for an user
*/
router.get("/getAll", async (req, res) => {
await validator.validate("getAccounts", req.params, res, Account.getAll);
});
/**
* Register a new mailbox inside the app
*/
router.post("/register", async (req, res) => {
await validator.validate("createAccount", req.body, res, Account.register);
});
/**
* Return all rooms from a mailbox
*/
router.get("/:mailboxId/rooms", async (req, res) => {
// todo offet limit
await validator.validate("getRooms", req.params, res, Account.getRooms);
});
export default router;

View File

@@ -1,60 +0,0 @@
import express from "express";
const router = express.Router();
import { rooms } from "../abl/rooms";
import Message from "../abl/Message-abl";
import { messages } from "../abl/messages";
import { members } from "../abl/members";
import Account from "../abl/Account-abl";
import validator from "../validator/validator";
import Room from "../abl/Room-abl";
/**
* Return all mailboxes and folders for an user
*/
router.get("/accounts", async (req, res) => {
await validator.validate("getAccounts", req.params, res, Account.getAll);
});
/**
* Return all rooms from a mailbox
*/
router.get("/:mailboxId/rooms", async (req, res) => {
// todo offet limit
await validator.validate("getRooms", req.params, res, rooms);
});
/**
* Return all messages from a room
*/
router.get("/:roomId/messages", async (req, res) => {
await validator.validate("getMessages", req.params, res, messages);
});
/**
* Return all members from a room
*/
router.get("/:roomId/members", async (req, res) => {
await validator.validate("getMembers", req.params, res, members);
});
/**
* Register a new mailbox inside the app
*/
router.post("/account", async (req, res) => {
await validator.validate("createAccount", req.body, res, Account.register);
});
router.post("/addFlag", async (req, res) => {
await validator.validate("addFlag", req.body, res, Message.addFlag);
});
router.post("/removeFlag", async (req, res) => {
await validator.validate("removeFlag", req.body, res, Message.removeFlag);
});
router.post("/response", async (req, res) => {
await validator.validate("response", req.body, res, Room.response);
});
export default router;

23
back/routes/message.ts Normal file
View File

@@ -0,0 +1,23 @@
import express from "express";
import MessageAbl from "../abl/Message-abl";
import validator from "../validator/validator";
const router = express.Router();
router.post("/addFlag", async (req, res) => {
await validator.validate("addFlag", req.body, res, MessageAbl.addFlag);
});
router.post("/removeFlag", async (req, res) => {
await validator.validate("removeFlag", req.body, res, MessageAbl.removeFlag);
});
router.post("/deleteRemote", async(req, res) => {
await validator.validate("delete", req.body, res, MessageAbl.deleteRemoteOnly);
});
router.post("/delete", async(req, res) => {
await validator.validate("delete", req.body, res, MessageAbl.deleteEverywhere);
});
export default router;

28
back/routes/room.ts Normal file
View File

@@ -0,0 +1,28 @@
import express from "express";
import RoomAbl from "../abl/Room-abl";
import validator from "../validator/validator";
const router = express.Router();
/**
* Return all messages from a room
*/
router.get("/:roomId/messages", async (req, res) => {
await validator.validate("getMessages", req.params, res, RoomAbl.getMessages);
});
/**
* Return all members from a room
*/
router.get("/:roomId/members", async (req, res) => {
await validator.validate("getMembers", req.params, res, RoomAbl.getMembers);
});
router.post("/response", async (req, res) => {
await validator.validate("response", req.body, res, RoomAbl.response);
});
router.post("/delete", async (req, res) => {
await validator.validate("deleteRoom", req.body, res, RoomAbl.delete);
});
export default router;

View File

@@ -2,8 +2,10 @@ import express from "express";
import cors from "cors";
const app = express();
import { execQueryAsync, execQuery } from "./db/db";
import mailRouter from "./routes/mail";
import emailManager from "./mails/EmailManager";
import accountRouter from "./routes/account";
import roomRouter from "./routes/room";
import messageRouter from "./routes/message";
app.use(express.json());
app.use(
@@ -14,8 +16,11 @@ app.use(
app.use(cors());
app.listen(process.env.PORT || 5500);
app.use("/api/mail", mailRouter);
app.use("/api/account", accountRouter);
app.use("/api/room", roomRouter);
app.use("/api/message", messageRouter);
// create imap and smtp instances for each account
emailManager.init();
// debug reset all tables
@@ -30,7 +35,7 @@ if (shouldReset) {
if (table.table_name == "mailbox") return;
console.log(table.table_name);
execQuery("DELETE FROM " + table.table_name, []);
// execQuery("DROP TABLE " + table.table_name);
// execQuery("DROP TABLE " + table.table_name, []);
});
});
}

View File

@@ -5,10 +5,12 @@
"pwd": { "type": "string" },
"xoauth": { "type": "string" },
"xoauth2": { "type": "string" },
"host": { "type": "string", "format": "hostname" },
"port": { "type": "number", "maximum": 65535 },
"imapHost": { "type": "string", "format": "hostname" },
"smtpHost": { "type": "string", "format": "hostname" },
"imapPort": { "type": "number", "maximum": 65535 },
"smtpPort": { "type": "number", "maximum": 65535 },
"tls": { "type": "boolean" }
},
"required": ["email", "host", "port", "tls"],
"required": ["email", "imapHost", "smtpHost", "imapPort", "smtpPort", "tls"],
"additionalProperties": false
}

View File

@@ -0,0 +1,16 @@
{
"type": "object",
"properties": {
"mailboxId": {
"type": "number"
},
"messageId": {
"type": "number"
}
},
"required": [
"mailboxId",
"messageId"
],
"additionalProperties": false
}

View File

@@ -0,0 +1,12 @@
{
"type": "object",
"properties": {
"roomId": {
"type": "number"
}
},
"required": [
"roomId"
],
"additionalProperties": false
}

View File

@@ -10,6 +10,8 @@ import getMessagesSchema from "./schemas/getMessages-schema.json";
import getMembersSchema from "./schemas/getMembers-schema.json";
import setFlagSchema from "./schemas/setFlag-schema.json";
import responseSchema from "./schemas/response-schema.json";
import deleteSchema from "./schemas/delete-schema.json";
import deleteRoomSchema from "./schemas/deleteRoom-schema.json";
import { Request, Response } from "express";
import statusCodes from "../utils/statusCodes";
import logger from "../system/Logger";
@@ -22,6 +24,8 @@ class Validator {
validateGetMembers: any;
validateSetFlag: any;
validateResponse: any;
delete: any;
deleteRoom: any;
constructor() {
this.validateCreateAccount = ajv.compile(createAccountSchema);
@@ -31,6 +35,8 @@ class Validator {
this.validateGetMembers = ajv.compile(getMembersSchema);
this.validateSetFlag = ajv.compile(setFlagSchema);
this.validateResponse = ajv.compile(responseSchema);
this.delete = ajv.compile(deleteSchema);
this.deleteRoom = ajv.compile(deleteRoomSchema);
}
_getSchema(name: string): any {
@@ -50,6 +56,10 @@ class Validator {
return this.validateSetFlag;
case "response":
return this.validateResponse;
case "delete":
return this.delete;
case "deleteRoom":
return this.deleteRoom;
default:
logger.err(`Schema ${name} not found`);
break;

View File

@@ -1,24 +0,0 @@
# mail
## Project setup
```
yarn install
```
### Compiles and hot-reloads for development
```
yarn serve
```
### Compiles and minifies for production
```
yarn build
```
### Lints and fixes files
```
yarn lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

View File

@@ -26,6 +26,7 @@
/* 343a46 */
}
/* .badge-primary { */
/* https://angel-rs.github.io/css-color-filter-generator/ */
.selected {
background-color: var(--selected);

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5.79297 5.20718L12.0001 11.4143L18.2072 5.20718L16.793 3.79297L12.0001 8.58586L7.20718 3.79297L5.79297 5.20718Z M18.2073 18.7928L12.0002 12.5857L5.79312 18.7928L7.20733 20.207L12.0002 15.4141L16.7931 20.207L18.2073 18.7928Z"></path></svg>

After

Width:  |  Height:  |  Size: 308 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M10.4143 4.58594L10.4142 11.0003L16.0003 11.0004L16.0003 13.0004L10.4142 13.0003L10.4141 19.4144L3 12.0002L10.4143 4.58594ZM18.0002 19.0002V5.00018H20.0002V19.0002H18.0002Z"></path></svg>

After

Width:  |  Height:  |  Size: 256 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M18.2073 9.04304L12.0002 2.83594L5.79312 9.04304L7.20733 10.4573L12.0002 5.66436L16.7931 10.4573L18.2073 9.04304Z M5.79297 14.9574L12.0001 21.1646L18.2072 14.9574L16.793 13.5432L12.0001 18.3361L7.20718 13.5432L5.79297 14.9574Z"></path></svg>

After

Width:  |  Height:  |  Size: 310 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M18 3V5H17V11L19 14V16H13V23H11V16H5V14L7 11V5H6V3H18ZM9 5V11.6056L7.4037 14H16.5963L15 11.6056V5H9Z"></path></svg>

After

Width:  |  Height:  |  Size: 184 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13.8273 1.68994L22.3126 10.1752L20.8984 11.5894L20.1913 10.8823L15.9486 15.125L15.2415 18.6605L13.8273 20.0747L9.58466 15.8321L4.63492 20.7818L3.2207 19.3676L8.17045 14.4179L3.92781 10.1752L5.34202 8.76101L8.87756 8.0539L13.1202 3.81126L12.4131 3.10416L13.8273 1.68994ZM14.5344 5.22548L9.86358 9.89631L7.0417 10.4607L13.5418 16.9608L14.1062 14.1389L18.7771 9.46812L14.5344 5.22548Z"></path></svg>

After

Width:  |  Height:  |  Size: 466 B

View File

@@ -0,0 +1,40 @@
<script setup>
import { defineProps } from "vue";
const props = defineProps({
onClick: { type: Function },
text: { type: String },
class: { type: String },
});
</script>
<template>
<span>
<button :class="props.class" @click="props.onClick">{{ props.text }}</button>
</span>
</template>
<style scoped lang="scss">
button {
padding: 5px;
padding: 7px 18px;
background-color: #09a35b;
color: var(--primary-text);
border-radius: 5px;
border: none;
text-decoration: none;
display: inline-block;
transition: opacity 0.5s;
&:hover {
opacity: 0.6;
}
&.danger {
background-color: var(--danger);
}
&.cancel {
background-color: var(--quaternary-background);
}
}
</style>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { defineProps, defineEmits, withDefaults } from "vue";
export interface Props {
type: string;
required: boolean;
onChange: any;
placeholder: string;
label: string;
vModel: string;
modelValue: any;
}
const props = withDefaults(defineProps<Props>(), {
required: () => false,
});
defineEmits(["update:modelValue"]);
</script>
<template>
<span>
<label v-show="props.label">{{ props.label }}</label>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
:type="props.type"
:required="props.required"
@change="props.onChange"
:placeholder="props.placeholder"
/>
</span>
</template>
<style scoped lang="scss">
label {
color: var(--secondary-text);
}
input {
-webkit-box-flex: 1;
background-color: var(--quaternary-background);
border: none;
border-radius: 4px;
color: var(--primary-text);
-ms-flex: 1;
flex: 1;
font-family: inherit;
font-size: 1.4rem;
font-weight: 400;
min-width: 0;
padding: 8px 9px;
&:focus {
outline: none;
}
}
/* Firefox */
input[type="number"] {
appearance: textfield;
}
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style>

View File

@@ -2,6 +2,8 @@
import { ref, computed, watchEffect } from "vue";
import Modal from "./Modal";
import API from "../../services/imapAPI";
import Input from "../basic/Input.vue";
import Button from "../basic/Button.vue";
const modal = ref(false);
@@ -9,35 +11,49 @@ const email = ref("");
const pwd = ref("");
const xoauth = ref("");
const xoauth2 = ref("");
const host = ref("");
const port = ref(993);
const imapHost = ref("");
const imapPort = ref(993);
const smtpHost = ref("");
const smtpPort = ref(465);
const error = ref("");
const knownHosts = {
"outlook.com": {
host: "outlook.office365.com",
imap: "outlook.office365.com",
smtp: "outlook.office365.com",
},
"hotmail.com": {
host: "outlook.office365.com",
imap: "outlook.office365.com",
smtp: "outlook.office365.com",
},
"live.com": {
host: "outlook.office365.com",
imap: "outlook.office365.com",
smtp: "outlook.office365.com",
},
"zoho.com": {
host: "imap.zoho.eu",
imap: "imap.zoho.eu",
smtp: "smtp.zoho.eu",
},
"yahoo.com": {
host: "imap.mail.yahoo.com",
imap: "imap.mail.yahoo.com",
smtp: "smtp.mail.yahoo.com",
},
"icloud.com": {
host: "imap.mail.me.com",
imap: "imap.mail.me.com",
smtp: "smtp.mail.me.com",
},
};
const refHost = computed({
const refSmtpHost = computed({
set: (val) => {
host.value = val;
smtpHost.value = val;
},
});
const refImapHost = computed({
set: (val) => {
imapHost.value = val;
},
});
@@ -60,7 +76,7 @@ function checkError() {
err.value = "You need at least one authentification method.";
} else if ([pwd.value, xoauth.value, xoauth2.value].filter((val) => val != "").length > 1) {
err.value = "You need only one authentification method.";
} else if (host.value == "" || port.value == "") {
} else if (smtpHost.value == "" || smtpPort.value == "" || imapHost.value == "" || imapPort.value == "") {
err.value = "You need to complete the port and the host.";
} else {
err.value = "";
@@ -75,8 +91,10 @@ function addAccountRequest() {
pwd: pwd.value,
xoauth: xoauth.value,
xoauth2: xoauth2.value,
host: host.value,
port: port.value,
imapHost: imapHost.value,
imapPort: imapPort.value,
smtpHost: smtpHost.value,
smtpPort: smtpPort.value,
tls: true,
};
@@ -98,76 +116,75 @@ watchEffect(() => {
function mailChange() {
if (email.value.includes("@")) {
const domain = email.value.split("@")[1];
if (!knownHosts[domain]) {
refHost.value = "imap." + domain;
} else {
refHost.value = knownHosts[domain].host;
if (imapHost.value == "") {
refImapHost.value = knownHosts[domain]?.imap ?? `imap.${domain}`;
}
if (smtpHost.value == "") {
refSmtpHost.value = knownHosts[domain]?.smtp ?? `smtp.${domain}`;
}
// todo check if manual
}
}
</script>
<template>
<div>
<button @click="modal = true">Add Account</button>
<Button :onClick="() => (modal = true)" text="Add Account" />
<Modal v-if="modal" title="Add new account" @close-modal="modal = false">
<template v-slot:body>
<div class="field">
<label>Email: </label>
<input @change="mailChange" v-model="email" type="email" required />
<Input
label="Email:"
:onChange="mailChange"
v-model="email"
type="email"
placeholder="email"
required
/>
</div>
<fieldset>
<div class="field">
<Input label="Plain password:" v-model="pwd" type="password" />
</div>
<!-- <fieldset>
<legend>Authentification method</legend>
<div class="field">
<label>Plain password:</label>
<input v-model="pwd" type="password" />
<Input label="Xoauth:" v-model="xoauth" type="text" />
</div>
<div class="field">
<label>Xoauth:</label>
<input v-model="xoauth" type="text" />
<Input label="Xoauth2:" v-model="xoauth2" type="text" />
</div>
<div class="field">
<label>Xoauth2:</label>
<input v-model="xoauth2" type="text" />
</div>
</fieldset>
</fieldset> -->
<div class="config">
<input v-model="host" id="host" type="text" placeholder="imap host" />
<input v-model="port" id="port" type="number" placeholder="port" />
</div>
<fieldset class="config">
<legend>Imap</legend>
<Input v-model="imapHost" class="host" type="text" placeholder="host" />
<Input v-model="imapPort" class="port" type="number" placeholder="port" />
</fieldset>
<fieldset class="config">
<legend>Smtp</legend>
<Input v-model="smtpHost" class="host" type="text" placeholder="host" />
<Input v-model="smtpPort" class="port" type="number" placeholder="port" />
</fieldset>
</template>
<!-- tls -->
<div>
<button :disabled="error != ''" @click="addAccountRequest">Add</button>
<template v-slot:actions>
<Button :disabled="error != ''" :onClick="addAccountRequest" text="Add" />
{{ error }}
</div>
</template>
</Modal>
</div>
</template>
<style>
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
appearance: textfield;
}
<style lang="scss">
.field {
margin-bottom: 5px;
}
.field > input {
.field {
input {
margin-top: 2px;
width: 95%;
}
}
fieldset {
@@ -179,54 +196,16 @@ fieldset {
}
.config {
display: block;
margin: 10px 0;
}
#host {
.host {
margin-right: 8px;
width: calc(95% - 100px);
}
#port {
.port {
display: inline-flex;
width: 70px;
}
button {
padding: 5px;
padding: 7px 18px;
background-color: #09a35b;
color: var(--primary-text);
border-radius: 5px;
border: none;
text-decoration: none;
display: inline-block;
transition: opacity 0.5s;
}
button:hover {
opacity: 0.6;
}
input {
-webkit-box-flex: 1;
background-color: var(--quaternary-background);
border: none;
border-radius: 4px;
color: var(--primary-text);
-ms-flex: 1;
flex: 1;
font-family: inherit;
font-size: 1.4rem;
font-weight: 400;
min-width: 0;
padding: 8px 9px;
}
label {
color: var(--secondary-text);
}
input:focus {
outline: none;
}
</style>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { ref, defineProps, withDefaults } from "vue";
import Modal from "./Modal.vue";
import Button from "../basic/Button.vue";
const modal = ref(false);
export interface Props {
title: string;
isDanger: boolean;
onConfirmation: any;
onCancel?: any;
}
const props = withDefaults(defineProps<Props>(), {
title: () => "Confirmation",
isDanger: () => true,
});
const error = ref("");
const handleContinue = async () => {
try {
if (props.onConfirmation) {
await props.onConfirmation();
}
modal.value = false;
} catch (err: any) {
error.value = err.message;
}
};
const handleCancel = async () => {
if (props.onCancel) {
await props.onCancel();
}
modal.value = false;
};
</script>
<template>
<div>
<Modal v-if="modal" :title="props.title" @close-modal="modal = false">
<template v-slot:body> Are you sure you want to do that ? </template>
<template v-slot:actions>
<Button :onClick="handleCancel" class="cancel" text="Cancel" />
<Button :onClick="handleContinue" :class="props.isDanger ? 'danger' : ''" text="Confirm" />
{{ error }}
</template>
</Modal>
</div>
</template>
<style lang="scss"></style>

View File

@@ -1,26 +1,59 @@
<script setup lang="ts">
import { Message } from "@/store/models/model";
import { ref, watch, defineProps, PropType } from "vue";
import { Address, Message, Room } from "@/store/models/model";
import { ref, Ref, watch, defineProps, PropType } from "vue";
import Content from "../structure/message/Content.vue";
import Modal from "./Modal.vue";
import MemberList from "@/views/room/MemberList.vue";
import { removeDuplicates } from "@/utils/array";
import { decodeEmojis } from "@/utils/string";
const props = defineProps({ messageId: { type: Number, require: true }, message: Object as PropType<Message> });
const props = defineProps({
messageId: { type: Number, require: true },
message: Object as PropType<Message>,
room: Object as PropType<Room>,
});
const messageId = ref(-1);
const fromA = ref<Address[]>([]);
const toA = ref<Address[]>([]);
const ccA = ref<Address[]>([]);
const getAddr = (ids: string | undefined, addrs: Address[] | undefined, res: Ref<Address[]>) => {
res.value = [];
if (!ids || !addrs) return;
let idsClean = removeDuplicates(ids.split(","));
idsClean.forEach((id) => {
let addr = addrs.find((member) => member.id === parseInt(id));
if (addr) {
res.value.push(addr);
}
});
};
watch(
() => props.messageId,
(newMessageId: number | undefined) => {
if (!newMessageId) return;
messageId.value = newMessageId;
getAddr(props.message?.fromA, props.room?.members, fromA);
getAddr(props.message?.toA, props.room?.members, toA);
getAddr(props.message?.ccA, props.room?.members, ccA);
},
);
</script>
<template>
<div class="main">
<Modal v-if="messageId != -1" @close-modal="() => $emit('close')">
<Modal v-if="messageId != -1" @close-modal="() => $emit('close')" id="modal">
<template v-slot:header>
<h2 id="header">
{{ decodeEmojis(props.message?.subject) ?? "No Object" }}
</h2>
</template>
<template v-slot:body>
<div>{{ props.message?.subject ?? "No Object" }}</div>
<MemberList type="from" :members="fromA" />
<MemberList type="to" :members="toA" />
<MemberList v-if="ccA.length > 0" type="cc" :members="ccA" />
<Content type="large" :content="props.message?.content" class="content" />
</template>
</Modal>
@@ -31,6 +64,11 @@ watch(
.main {
min-width: 700px;
}
#header {
width: 700px;
}
/* todo define size automatically */
.content {
width: 700px;

View File

@@ -26,13 +26,18 @@ onUnmounted(() => {
<div class="modal-wrapper">
<div class="modal" v-on-click-outside="close">
<header class="modal-header">
<slot name="header">
<h2>{{ props.title }}</h2>
</slot>
<div class="close-button" @click="close"></div>
</header>
<div class="modal-body">
<slot name="body"> This is the default body! </slot>
</div>
<div class="modal-actions">
<slot name="actions"></slot>
</div>
</div>
</div>
</template>
@@ -58,13 +63,29 @@ onUnmounted(() => {
border-radius: 5px;
color: var(--primary-text);
background-color: var(--secondary-background);
padding: 20px;
padding: 10px;
min-width: 220px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.modal-body {
margin: 10px;
}
.modal-actions {
display: flex;
align-items: center;
flex-direction: row;
justify-content: flex-end;
gap: 10px;
}
h2 {
display: inline-block;
font-size: 2.4rem;

View File

@@ -84,6 +84,19 @@ function sendMessage() {
});
}
const getClasses = (isActive, disabled = false) => {
let classes = [];
if (isActive) {
classes.push("is-active");
}
if (!disabled) {
classes.push("selectable");
} else {
classes.push("disabled");
}
return classes.join(",");
};
// todo subject input when dm of group...
// Font selection: choose the font family, size, color, and style
// Images: insert pictures and graphics into your email
@@ -99,31 +112,31 @@ function sendMessage() {
<SvgLoader
svg="bold"
@click="editor.chain().focus().toggleBold().run()"
:class="[{ 'is-active': editor.isActive('bold') }, 'editorOption']"
:classes="getClasses(editor.isActive('bold'))"
v-tooltip="{ text: 'Bold', shortcut: ['Ctrl', 'B'] }"
/>
<SvgLoader
svg="italic"
@click="editor.chain().focus().toggleItalic().run()"
:class="[{ 'is-active': editor.isActive('italic') }, 'editorOption']"
:classes="getClasses(editor.isActive('italic'))"
v-tooltip="{ text: 'Italic', shortcut: ['Ctrl', 'I'] }"
/>
<SvgLoader
svg="strikethrough"
@click="editor.chain().focus().toggleStrike().run()"
:class="[{ 'is-active': editor.isActive('strike') }, 'editorOption']"
:classes="getClasses(editor.isActive('strike'))"
v-tooltip="{ text: 'Strike', shortcut: ['Ctrl', 'Shift', 'X'] }"
/>
<SvgLoader
svg="underline"
@click="editor.chain().focus().toggleUnderline().run()"
:class="[{ 'is-active': editor.isActive('underline') }, 'editorOption']"
:classes="getClasses(editor.isActive('underline'))"
v-tooltip="{ text: 'Underline', shortcut: ['Ctrl', 'U'] }"
/>
<SvgLoader
svg="font-color"
@click="editor.commands.toggleHighlight({ color: '#ffcc00' })"
:class="[{ 'is-active': editor.isActive('highlight') }, 'editorOption']"
:classes="getClasses(editor.isActive('highlight'))"
v-tooltip="{ text: 'Highlight', shortcut: ['Ctrl', 'Shift', 'H'] }"
/>
</span>
@@ -131,19 +144,19 @@ function sendMessage() {
<SvgLoader
svg="h-2"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
:class="[{ 'is-active': editor.isActive({ level: 2 }) }, 'editorOption']"
:classes="getClasses(editor.isActive({ level: 2 }))"
v-tooltip="{ text: 'Heading 2', shortcut: ['Ctrl', 'Alt', '2'] }"
/>
<SvgLoader
svg="h-3"
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
:class="[{ 'is-active': editor.isActive({ level: 3 }) }, 'editorOption']"
:classes="getClasses(editor.isActive({ level: 3 }))"
v-tooltip="{ text: 'Heading 3', shortcut: ['Ctrl', 'Alt', '3'] }"
/>
<SvgLoader
svg="h-4"
@click="editor.chain().focus().toggleHeading({ level: 4 }).run()"
:class="[{ 'is-active': editor.isActive({ level: 4 }) }, 'editorOption']"
:classes="getClasses(editor.isActive({ level: 4 }))"
v-tooltip="{ text: 'Heading 4', shortcut: ['Ctrl', 'Alt', '4'] }"
/>
</span>
@@ -156,13 +169,13 @@ function sendMessage() {
<SvgLoader
svg="list-ordered"
@click="editor.chain().focus().toggleOrderedList().run()"
:class="[{ 'is-active': editor.isActive('orderedList') }, 'editorOption']"
:classes="getClasses(editor.isActive('orderedList'))"
v-tooltip="{ text: 'Ordered List', shortcut: ['Ctrl', 'Alt', '7'] }"
/>
<SvgLoader
svg="list-unordered"
@click="editor.chain().focus().toggleBulletList().run()"
:class="[{ 'is-active': editor.isActive('bulletList') }, 'editorOption']"
:classes="getClasses(editor.isActive('bulletList'))"
v-tooltip="{ text: 'Unordered List', shortcut: ['Ctrl', 'Alt', '8'] }"
/>
</span>
@@ -170,26 +183,26 @@ function sendMessage() {
<SvgLoader
svg="align-left"
@click="editor.chain().focus().setTextAlign('left').run()"
:class="[{ 'is-active': editor.isActive({ textAlign: 'left' }) }, 'editorOption']"
:classes="getClasses(editor.isActive({ textAlign: 'left' }))"
v-tooltip="{ text: 'Align Left', shortcut: ['Ctrl', 'Shift', 'L'] }"
/>
<SvgLoader
svg="align-center"
@click="editor.chain().focus().setTextAlign('center').run()"
:class="[{ 'is-active': editor.isActive({ textAlign: 'center' }) }, 'editorOption']"
v-tooltip="{ text: 'Align Left', shortcut: ['Ctrl', 'Shift', 'E'] }"
:classes="getClasses(editor.isActive({ textAlign: 'center' }))"
v-tooltip="{ text: 'Align Center', shortcut: ['Ctrl', 'Shift', 'E'] }"
/>
<SvgLoader
svg="align-right"
@click="editor.chain().focus().setTextAlign('right').run()"
:class="[{ 'is-active': editor.isActive({ textAlign: 'right' }) }, 'editorOption']"
v-tooltip="{ text: 'Align Left', shortcut: ['Ctrl', 'Shift', 'R'] }"
:classes="getClasses(editor.isActive({ textAlign: 'right' }))"
v-tooltip="{ text: 'Align Right', shortcut: ['Ctrl', 'Shift', 'R'] }"
/>
<SvgLoader
svg="align-justify"
@click="editor.chain().focus().setTextAlign('justify').run()"
:class="[{ 'is-active': editor.isActive({ textAlign: 'justify' }) }, 'editorOption']"
v-tooltip="{ text: 'Align Left', shortcut: ['Ctrl', 'Shift', 'J'] }"
:classes="getClasses(editor.isActive({ textAlign: 'justify' }))"
v-tooltip="{ text: 'Justify', shortcut: ['Ctrl', 'Shift', 'J'] }"
/>
</span>
@@ -207,14 +220,14 @@ function sendMessage() {
<SvgLoader
svg="indent-increase"
@click="!$event.disabled ? editor.chain().focus().sinkListItem('listItem').run() : ''"
:class="[{ disabled: !editor.can().sinkListItem('listItem') }, 'editorOption']"
:classes="getClasses(false, !editor.can().sinkListItem('listItem'))"
:isDisabled="!editor.can().sinkListItem('listItem')"
v-tooltip="{ text: 'Sink Item', shortcut: ['Tab'] }"
/>
<SvgLoader
svg="indent-decrease"
@click="!$event.disabled ? editor.chain().focus().liftListItem('listItem').run() : ''"
:class="[{ disabled: !editor.can().liftListItem('listItem') }, 'editorOption']"
:classes="getClasses(false, !editor.can().liftListItem('listItem'))"
:isDisabled="!editor.can().liftListItem('listItem')"
v-tooltip="{ text: 'Lift Item', shortcut: ['Shift', 'Tab'] }"
/>
@@ -238,7 +251,8 @@ function sendMessage() {
<SvgLoader
svg="italic"
@click="editor.chain().focus().toggleItalic().run()"
:class="[{ 'is-active': editor.isActive('italic') }, 'editorOption']"
:class="[{ 'is-active,selectable': editor.isActive('italic') }, 'editorOption']"
:classes="[{ 'is-active,selectable': editor.isActive('italic') }, 'selectable'].join()"
/>
<SvgLoader
svg="strikethrough"
@@ -281,6 +295,7 @@ function sendMessage() {
display: flex;
flex-direction: row;
padding: 0 10px 10px 10px;
min-height: 90px;
max-height: 500px;
overflow: auto;
}
@@ -296,6 +311,7 @@ function sendMessage() {
border-radius: 10px;
padding: 0 10px;
overflow: auto;
min-height: 80px;
}
.bubble-menu,
@@ -319,20 +335,6 @@ function sendMessage() {
padding: 2px;
}
.editorOption {
border-radius: 6px;
cursor: pointer;
&:hover,
&.is-active {
background-color: var(--selected);
}
}
.disabled {
opacity: 0.5;
}
.actions {
display: flex;
flex-direction: column;

View File

@@ -1,27 +1,25 @@
<script setup lang="ts">
import { defineProps, PropType } from "vue";
import { defineProps, withDefaults, ref } from "vue";
import { decodeEmojis } from "../../../utils/string";
import { removeDuplicates } from "../../../utils/array";
import { Address, Message } from "@/store/models/model";
import Content from "./Content.vue";
import Options from "./Options.vue";
import { isSeenFc } from "@/utils/flagsUtils";
import SvgLoader from "@/components/utils/SvgLoader.vue";
import { useRouter } from "vue-router";
import { displayAddresses } from "@/utils/address";
const props = defineProps({
msg: Object as PropType<Message>,
members: Array as PropType<Address[]>,
export interface Props {
msg: Message;
members: Address[];
compact: boolean;
}
const props = withDefaults(defineProps<Props>(), {
compact: () => true,
});
const displayAddresses = (addressIds: string[] | undefined): string => {
if (!addressIds) return "";
addressIds = removeDuplicates(addressIds);
let res = "";
addressIds.forEach((addressId) => {
const address = props.members?.find((member) => member.id === parseInt(addressId));
if (address) res += address.email;
});
return res;
};
const compact = ref(props.compact);
const classes = (): string => {
const flags = props.msg?.flags;
@@ -35,6 +33,17 @@ const classes = (): string => {
}
return "msg-basic";
};
const style = (prop: string): string => {
// get color of the member that send the message
let member = props.members?.find((member) => member.id === parseInt(props.msg?.fromA?.split(",")[0]));
if (member?.color) {
return `${prop}: ${member.color} !important`;
}
return "";
};
const router = useRouter();
</script>
<!-- to if to is more than me
cc -->
@@ -43,9 +52,9 @@ const classes = (): string => {
attachments -->
<template>
<div class="message" @dblclick="$emit('openMessageView', props.msg?.id)">
<div id="context">
<div id="context" :style="style('background-color')">
<div class="left" id="profile">
{{ displayAddresses(props.msg?.fromA?.split(",")) }} - {{ props.msg?.fromA }}
{{ displayAddresses(props.msg?.fromA?.split(","), props.members) }} - {{ props.msg?.fromA }}
</div>
<div class="middle">{{ decodeEmojis(props.msg?.subject) }}</div>
<div class="right" id="date">
@@ -62,20 +71,44 @@ const classes = (): string => {
}}
</div>
</div>
<div class="content" :class="[classes()]">
<Content :content="props.msg?.content" />
<div class="content" :class="[classes()]" :style="style('border-color')">
<Content v-if="!compact" :content="props.msg?.content" />
<SvgLoader
v-if="compact"
classes="selectable"
class="expand-contract"
svg="expand-up-down-line"
@click="compact = false"
/>
<Options class="options" :msg="props.msg" />
</div>
<div id="thread-link" v-if="props.msg?.thread" @click="router.push(`/${props.msg?.thread}`)">
<SvgLoader svg="expand-left-fill" />
<span>Go to the full conversation.</span>
</div>
</div>
</template>
<style scoped>
<style scoped lang="scss">
.message {
width: auto;
/* border: white 1px solid; */
margin: 10px 5px 0px 5px;
}
#thread-link {
&:hover {
background-color: var(--selected);
}
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--quaternary-background);
padding: 3px 10px;
border-radius: 4px 4px 0 0;
cursor: pointer;
}
#context {
display: flex;
flex-direction: row;
@@ -97,8 +130,15 @@ const classes = (): string => {
.content {
display: flex;
padding-top: 6px;
padding: 6px;
flex-direction: row;
border: solid 4px var(--quaternary-background);
border-top: 0;
border-radius: 0 0 4px 4px;
}
.expand-contract {
margin: 0 50px;
}
.msg-important {

View File

@@ -14,6 +14,7 @@ const room: any = inject("room");
const seenLoading = ref(false);
const flaggedLoading = ref(false);
const deletionLoading = ref(false);
const setReadFlag = () => setFlag("\\Seen", seenLoading);
const setFlaggedFlag = () => setFlag("\\Flagged", flaggedLoading);
@@ -42,6 +43,42 @@ const setFlag = (flag: string, loadingState: Ref<boolean>) => {
loadingState.value = false;
});
};
const deleteEverywhere = () => {
if (deletionLoading.value) return;
if (!room?.value || !props.msg) return;
deletionLoading.value = true;
API.deleteEverywhere({ mailboxId: room.value?.mailboxId, messageId: props.msg?.id })
.then((res) => {
// delete even if we delete the room after because the transition between room is not clean (todo)
store.commit("removeMsg", { roomId: room.value?.id, messageId: props.msg?.id });
if (res.data?.deleteRoom) {
store.commit("removeRoom", { roomId: room.value?.id });
}
deletionLoading.value = false;
})
.catch((err) => {
console.log(err);
deletionLoading.value = false;
});
};
const deleteRemoteOnly = () => {
if (deletionLoading.value) return;
if (!room?.value || !props.msg) return;
deletionLoading.value = true;
API.deleteRemoteOnly({ mailboxId: room.value?.mailboxId, messageId: props.msg?.id })
.then((res) => {
if (!hasFlag(props.msg?.flags, "\\Deleted")) {
store.commit("addFlag", { roomId: room.value?.id, messageId: props.msg?.id, flag: "\\Deleted" });
}
deletionLoading.value = false;
})
.catch((err) => {
console.log(err);
deletionLoading.value = false;
});
};
</script>
<template>
@@ -51,14 +88,14 @@ const setFlag = (flag: string, loadingState: Ref<boolean>) => {
<SvgLoader
v-if="isSeenFc(props.msg?.flags)"
svg="mail-check-line"
class="option"
classes="selectable"
v-tooltip="'Mark unread'"
:loading="seenLoading"
/>
<SvgLoader
v-if="!isSeenFc(props.msg?.flags)"
svg="mail-unread-line"
class="option"
classes="selectable"
v-tooltip="'Mark as read'"
:loading="seenLoading"
/>
@@ -66,39 +103,34 @@ const setFlag = (flag: string, loadingState: Ref<boolean>) => {
<span @click="setFlaggedFlag()">
<SvgLoader
svg="flag-line"
class="option"
:loading="flaggedLoading"
:classes="hasFlag(props.msg?.flags, '\\Flagged') ? 'warn' : ''"
:classes="(hasFlag(props.msg?.flags, '\\Flagged') ? 'warn' : '') + ',selectable'"
v-tooltip="hasFlag(props.msg?.flags, '\\Flagged') ? 'Unflag' : 'Flag'"
/>
<!--
<SvgLoader
v-if="isSeenFc(props.msg?.flags)"
svg="mail-check-line"
class="option"
v-tooltip="'Mark unread'"
:loading="seenLoading"
/>
<SvgLoader
v-if="!isSeenFc(props.msg?.flags)"
svg="mail-unread-line"
class="option"
v-tooltip="'Mark as read'"
:loading="seenLoading"
/> -->
</span>
<SvgLoader svg="reply-line" class="option" />
<SvgLoader svg="delete-bin-4-line" class="option" classes="danger" v-tooltip="'Delete from server'" />
<SvgLoader svg="delete-bin-6-line" class="option" classes="danger" v-tooltip="'Delete everywhere'" />
<SvgLoader svg="share-forward-line" class="option" />
<SvgLoader svg="reply-all-line" class="option" />
</div>
<div>reply</div>
<div>delete from all</div>
<div>delete from remote</div>
<div>transfer</div>
<div>see source</div>
<SvgLoader svg="reply-line" classes="selectable" />
<span @click="deleteRemoteOnly()">
<SvgLoader
svg="delete-bin-4-line"
:loading="deletionLoading"
classes="danger,selectable"
v-tooltip="'Delete from remote'"
/>
</span>
<span @click="deleteEverywhere()">
<SvgLoader
svg="delete-bin-6-line"
:loading="deletionLoading"
classes="danger,selectable"
v-tooltip="'Delete everywhere'"
/>
</span>
<SvgLoader svg="share-forward-line" classes="selectable" />
<SvgLoader svg="reply-all-line" classes="selectable" />
<div>{{ props.msg?.flags }}</div>
</div>
<!-- <div>see source</div> -->
</div>
</template>
<style lang="scss" scoped>
@@ -110,27 +142,6 @@ const setFlag = (flag: string, loadingState: Ref<boolean>) => {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.button {
border: solid 1px;
border-radius: 6px;
display: initial;
padding: 1px 5px;
cursor: pointer;
&:hover {
background-color: var(--selected);
}
}
.option {
border-radius: 6px;
cursor: pointer;
&:hover,
&.is-active {
background-color: var(--selected);
}
align-items: center;
}
</style>

View File

@@ -5,27 +5,41 @@ const props = defineProps({
svg: { type: String, required: true },
isDisabled: Boolean,
classes: String,
loading: { type: Boolean, default: false },
loading: { type: Boolean },
});
console.log(props.loading);
const pathSvg = () => require(`@/assets/svg/${props.svg}.svg`);
const classes = () => props.classes?.split(",") ?? "";
</script>
<template>
<div class="mainSvg">
<div class="mainSvg" :class="classes()">
<div class="lds-dual-ring" v-if="loading"></div>
<img v-if="!loading" :disabled="props.isDisabled" :src="pathSvg()" :class="props.classes" />
<img v-if="!loading" :disabled="props.isDisabled" :src="pathSvg()" :class="classes()" />
</div>
</template>
<style lang="scss" scoped>
.mainSvg {
display: inherit;
display: flex;
&.selectable {
display: inline-block;
border-radius: 6px;
cursor: pointer;
&:hover,
&.is-active {
background-color: var(--selected);
}
}
.disabled {
opacity: 0.5;
}
}
img {
padding: 1px;
min-width: 26px;
min-height: 26px;
filter: var(--svg-primary-text);
&.danger {
filter: var(--svg-danger);
@@ -37,7 +51,7 @@ img {
}
.lds-dual-ring {
display: inline-block;
display: flex;
width: 26px;
height: 26px;
}

View File

@@ -2,28 +2,36 @@ import API from "./API";
export default {
registerAccount(data: object) {
return API().post("/mail/account", data);
return API().post("/account/register", data);
},
getAccounts() {
return API().get("/mail/accounts");
return API().get("/account/getAll");
},
getRooms(mailboxId: number) {
return API().get(`/mail/${mailboxId}/rooms`);
return API().get(`/account/${mailboxId}/rooms`);
},
getMessages(roomId: number) {
return API().get(`/mail/${roomId}/messages`);
return API().get(`/room/${roomId}/messages`);
},
getMembers(roomId: number) {
return API().get(`/mail/${roomId}/members`);
return API().get(`/room/${roomId}/members`);
},
addFlag(data: { mailboxId: number; messageId: number; flag: string }) {
return API().post(`/mail/addFlag`, data);
return API().post(`/message/addFlag`, data);
},
removeFlag(data: { mailboxId: number; messageId: number; flag: string }) {
return API().post(`/mail/removeFlag`, data);
return API().post(`/message/removeFlag`, data);
},
reponseEmail(data: { user: string; roomId: number; text: string; html: string }) {
console.log(data);
return API().post(`/mail/response`, data);
return API().post(`/room/response`, data);
},
deleteRemoteOnly(data: { mailboxId: number; messageId: number }) {
return API().post(`/message/deleteRemote`, data);
},
deleteEverywhere(data: { mailboxId: number; messageId: number }) {
return API().post(`/message/delete`, data);
},
deleteRoom(id: number) {
return API().post(`/room/delete`, { roomId: id });
},
};

View File

@@ -15,6 +15,7 @@ export interface Message {
content: string;
date: string;
flags: string[];
thread: number | null;
}
export enum LoadingState {
@@ -33,6 +34,7 @@ export interface Room {
members: Address[];
notSeen: number;
threadIds: number[];
parent_id?: number;
}
export interface Account {
@@ -45,5 +47,6 @@ export interface Address {
id: number;
name: string | null;
email: string;
color: string | undefined;
type: string;
}

View File

@@ -4,6 +4,7 @@ import { decodeEmojis } from "@/utils/string";
import { AxiosError, AxiosResponse } from "axios";
import { createStore } from "vuex";
import { Room, Account, Address, RoomType, Message, LoadingState } from "./models/model";
import { removeDuplicates } from "@/utils/array";
interface RoomFromBack {
id: number;
@@ -32,6 +33,7 @@ function createRoom(options: RoomFromBack): Room {
user: options.user,
notSeen: options.notSeen,
threadIds: [],
parent_id: options.parent_id,
};
}
@@ -76,8 +78,6 @@ function updateSeen(state: State, roomId: number, flag: string[], adding: boolea
}
}
}
// define injection key todo
// export const key: InjectionKey<Store<State>> = Symbol()
const store = createStore<State>({
state: {
@@ -95,9 +95,6 @@ const store = createStore<State>({
},
setActiveRoom(state, payload) {
state.activeRoom = payload;
// todo load room on load page
const room = roomOnId(state, payload);
if (!room) return;
let roomMessage = msgOnRoomId(state, payload);
if (!roomMessage) {
state.roomMessages.push({ messages: [], fetch: LoadingState.notLoaded, roomId: payload });
@@ -114,7 +111,6 @@ const store = createStore<State>({
});
},
addRooms(state, payload) {
// todo add if not exist
const buffer: RoomFromBack[] = [];
payload.rooms.forEach((room: RoomFromBack) => {
if (room.roomType == RoomType.THREAD) {
@@ -132,19 +128,37 @@ const store = createStore<State>({
}
});
},
removeRoom(state, payload) {
const roomMessageIndex = state.roomMessages.findIndex((roomM) => roomM.roomId === payload.roomId);
state.roomMessages.splice(roomMessageIndex, 1);
const roomIndex = state.rooms.findIndex((room) => room.id === payload.roomId);
const roomToDelete = state.rooms[roomIndex];
// todo debug parent_id to root_id
// remove thread
if (roomToDelete.roomType === RoomType.THREAD && roomToDelete.parent_id) {
const parentRoom = roomOnId(state, roomToDelete.parent_id);
if (parentRoom) {
parentRoom.threadIds = parentRoom?.threadIds.filter((id) => id !== roomToDelete.id);
}
}
// todo fix to many at the same time bug
state.rooms.splice(roomIndex, 1);
// state.activeRoom = state.rooms[0].id;
// router.push(`/${state.activeRoom}`);
},
addMessages(state, payload) {
// todo add if not exist
const room = roomOnId(state, payload.roomId);
if (!room) return;
let roomMessage = msgOnRoomId(state, payload.roomId);
if (!roomMessage) {
state.roomMessages.push({ roomId: payload.roomId, messages: [], fetch: LoadingState.notLoaded });
roomMessage = msgOnRoomId(state, payload.roomId);
}
if (!roomMessage) return;
payload.messages.forEach((message: any) => {
message.flags = message.flags?.split(",") ?? [];
// todo fix upstream
// message.fromA = message.fromA ? removeDuplicates(message.fromA.split(",").join(",")) : null;
// message.toA = message.toA ? removeDuplicates(message.toA.split(",").join(",")) : null;
// message.ccA = message.ccA ? removeDuplicates(message.ccA.split(",").join(",")) : null;
roomMessage?.messages.push(message);
});
},
@@ -170,6 +184,19 @@ const store = createStore<State>({
updateSeen(state, payload.roomId, payload.flag, false);
}
},
removeMsg(state, payload) {
const msgs = msgOnRoomId(state, payload.roomId);
const msgIndex = msgs?.messages.findIndex((msg) => msg.id == payload.messageId) ?? -1;
if (msgs && msgIndex != -1) {
if (!isSeenFc(msgs.messages[msgIndex].flags)) {
const room = roomOnId(state, payload.roomId);
if (room) {
room.notSeen = room.notSeen - 1;
}
}
msgs.messages.splice(msgIndex, 1);
}
},
},
getters: {
rooms: (state) => (): Room[] => {
@@ -193,9 +220,14 @@ const store = createStore<State>({
messages:
(state) =>
(roomId: number): Message[] => {
if (!roomId) return [];
const roomMessage = msgOnRoomId(state, roomId);
if (roomMessage) return roomMessage.messages;
if (roomMessage && roomMessage.fetch === LoadingState.loaded) return roomMessage.messages;
if (!roomMessage) {
state.roomMessages.push({ messages: [], roomId: roomId, fetch: LoadingState.notLoaded });
} else if (roomMessage.fetch === LoadingState.loaded) {
return roomMessage.messages;
}
store.dispatch("fetchMessages", { roomId: roomId, obj: msgOnRoomId(state, roomId) });
return msgOnRoomId(state, roomId)?.messages ?? [];
},

View File

@@ -0,0 +1,13 @@
import { Address } from "@/store/models/model";
import { removeDuplicates } from "./array";
export const displayAddresses = (addressIds: string[] | undefined, members: Address[]): string => {
if (!addressIds) return "";
addressIds = removeDuplicates(addressIds);
let res = "";
addressIds.forEach((addressId) => {
const address = members?.find((member) => member.id === parseInt(addressId));
if (address) res += address.email;
});
return res;
};

View File

@@ -1,5 +1,5 @@
export function removeDuplicates(array: number[]) {
const unique: number[] = [];
export function removeDuplicates(array: any[]) {
const unique: any[] = [];
for (let i = 0; i < array.length; i++) {
if (!unique.includes(array[i])) {
unique.push(array[i]);

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { defineProps, PropType } from "vue";
import Badge from "@/components/Badge.vue";
import SvgLoader from "@/components/utils/SvgLoader.vue";
import { RoomType, Address, Room } from "@/store/models/model";
import MemberList from "./MemberList.vue";
import imapAPI from "@/services/imapAPI";
const props = defineProps({ id: Number, room: Object as PropType<Room> });
@@ -14,21 +16,38 @@ const roomTitle = () => {
return props.room?.roomName;
};
const handleDelete = () => {
console.log(props.room);
if (props.room?.id) {
imapAPI.deleteRoom(props.room.id);
}
// todo loading, delete
};
// todo remove us from list
const to = () => props.room?.members.filter((member: Address) => member.type == "to");
const cc = () => props.room?.members.filter((member: Address) => member.type == "cc");
</script>
<template>
<div class="main">
<div>
<div class="context">
<div class="infos">
<Badge :value="RoomType[room?.roomType ?? 0]" />
{{ roomTitle() }}
</div>
<div class="action">action: threads message important</div>
<div class="action">
<SvgLoader svg="list-unordered" classes="selectable" v-tooltip="{ text: 'Thread list' }" />
<SvgLoader svg="pushpin-line" classes="selectable" v-tooltip="{ text: 'Important messages' }" />
<SvgLoader
svg="delete-bin-4-line"
@click="handleDelete()"
classes="danger,selectable"
v-tooltip="{ text: 'Delete room' }"
/>
</div>
<div class="members" v-if="room?.roomType != RoomType.DM">
</div>
<div v-if="room?.roomType != RoomType.DM">
<MemberList class="members-list" v-if="to()?.length ?? 0 > 0" type="to" :members="to()" />
<MemberList class="members-list" v-if="cc()?.length ?? 0 > 0" type="cc" :members="cc()" />
</div>
@@ -36,8 +55,6 @@ const cc = () => props.room?.members.filter((member: Address) => member.type ==
</template>
<style scoped>
.main {
}
.context {
display: flex;
flex-direction: row;
@@ -52,9 +69,6 @@ const cc = () => props.room?.members.filter((member: Address) => member.type ==
padding: 3px 5px;
border-bottom: 1px solid var(--border-color);
}
.members {
}
.infos {
margin-left: 15px;
}

View File

@@ -7,18 +7,21 @@ import Header from "./Header.vue";
import Message from "../../components/structure/message/Message.vue";
import MessageViewModal from "@/components/modals/MessageViewModal.vue";
import Composer from "@/components/structure/message/Composer.vue";
import ConfirmationModal from "@/components/modals/ConfirmationModal.vue";
const store = useStore();
const route = useRoute();
const messageIdView = ref(-1);
const message = ref(undefined);
const messages = ref([]);
const id = ref(parseInt(route.params.id));
let room = ref();
onBeforeMount(async () => {
console.log(id.value);
store.commit("setActiveRoom", id.value);
room.value = store.getters.room(id.value);
console.log(room.value);
messages.value = store.getters.messages(id.value);
});
onBeforeRouteUpdate(async (to, from) => {
@@ -26,7 +29,7 @@ onBeforeRouteUpdate(async (to, from) => {
id.value = parseInt(to.params.id);
store.commit("setActiveRoom", id.value);
room.value = await store.getters.room(id.value);
console.log(room.value);
messages.value = store.getters.messages(id.value);
}
});
@@ -45,6 +48,13 @@ function openMessageView(messageId) {
message.value = store.getters.message(room.value.id, messageId);
}
const shouldBeCompact = (index) => {
// show last three messages
// todo fix not changing three displayed when deleting
if (messages.value?.length - 4 < index) return false;
return true;
};
provide("room", room);
</script>
@@ -53,15 +63,23 @@ provide("room", room);
<Header :id="id" :room="room"></Header>
<div class="messages">
<Message
v-for="(message, index) in store.getters.messages(room?.id)"
v-for="(message, index) in messages"
:key="index"
:msg="message"
:members="room?.members"
:compact="shouldBeCompact(index)"
@open-message-view="(id) => openMessageView(id)"
/>
</div>
<Composer v-if="shouldDisplayComposer() || true" />
<MessageViewModal :message="message" :messageId="messageIdView" @close="() => openMessageView(-1)" />
<MessageViewModal
:room="room"
:message="message"
:messageId="messageIdView"
@close="() => openMessageView(-1)"
/>
<!-- todo -->
<!-- <ConfirmationModal /> -->
</div>
</template>

View File

@@ -1,3 +1,8 @@
<script lang="ts" setup>
import Accounts from "./accounts/Accounts.vue";
import Rooms from "./rooms/Rooms.vue";
</script>
<template>
<div>
<Accounts class="accounts" />
@@ -5,19 +10,6 @@
</div>
</template>
<script>
import Accounts from "./accounts/Accounts";
import Rooms from "./rooms/Rooms.vue";
export default {
name: "Sidebar",
components: {
Accounts,
Rooms,
},
};
</script>
<style scoped>
div {
display: flex;

View File

@@ -1,3 +1,17 @@
<script setup>
import Account from "./Account";
import AddAccountModal from "@/components/modals/AddAccountModal";
import store from "@/store/store";
import { onMounted } from "vue";
import { computed } from "@vue/reactivity";
onMounted(() => {
store.dispatch("fetchAccounts");
});
const accounts = computed(() => store.state.accounts);
</script>
<template>
<div id="main">
<div id="userMenu">
@@ -6,32 +20,10 @@
<span class="divider"></span>
<Account v-for="(account, index) in accounts" :key="index" :data="account" />
<span class="divider"></span>
<AddAccountModal />
</div>
</template>
<script>
import { mapState } from "vuex";
import Account from "./Account";
import AddAccountModal from "@/components/modals/AddAccountModal";
import store from "@/store/store";
export default {
name: "Accounts",
components: {
Account,
AddAccountModal,
},
computed: {
...mapState(["accounts"]),
},
created() {
store.dispatch("fetchAccounts");
},
};
</script>
<style scoped>
#main {
display: flex;