mirror of
https://github.com/1f349/mail.1f349.com.git
synced 2024-11-09 22:32:52 +00:00
Start message loading classes
This commit is contained in:
parent
d937f18a19
commit
1be9ad77b5
224
src/App.svelte
224
src/App.svelte
@ -2,33 +2,20 @@
|
|||||||
import {onMount} from "svelte";
|
import {onMount} from "svelte";
|
||||||
import {getBearer, loginStore} from "./stores/login";
|
import {getBearer, loginStore} from "./stores/login";
|
||||||
import {openLoginPopup} from "./utils/login-popup";
|
import {openLoginPopup} from "./utils/login-popup";
|
||||||
import type {ImapFolder, ImapMessage} from "./types/imap";
|
import type {ImapMessage} from "./types/imap";
|
||||||
import type {TreeFolder, RootFolder, FolderSelection} from "./types/internal";
|
|
||||||
import TreePath from "./components/TreePath.svelte";
|
import TreePath from "./components/TreePath.svelte";
|
||||||
import MailView from "./components/MailView.svelte";
|
import MailView from "./components/MailView.svelte";
|
||||||
import FolderView from "./components/FolderView.svelte";
|
import FolderView from "./components/FolderView.svelte";
|
||||||
import type {Folders} from "lucide-svelte";
|
import {WS} from "./logic/ws";
|
||||||
|
import {FolderManager, MailFolder} from "./logic/folder";
|
||||||
|
import {MessageManager} from "./logic/message";
|
||||||
|
|
||||||
let mainWS: WebSocket;
|
let mainWS: WS;
|
||||||
$: window.mainWS = mainWS;
|
$: window.mainWS = mainWS;
|
||||||
|
|
||||||
// Setup root folders
|
let fm: FolderManager = new FolderManager();
|
||||||
let INBOX: RootFolder = {role: "Inbox", name: "Inbox", path: "INBOX", attr: new Set(), children: []};
|
let mm: MessageManager = new MessageManager();
|
||||||
let DRAFTS: RootFolder = {role: "Drafts", name: "Drafts", path: "", attr: new Set(["\\Drafts"]), children: []};
|
|
||||||
let SENT: RootFolder = {role: "Sent", name: "Sent", path: "", attr: new Set(["\\Sent"]), children: []};
|
|
||||||
let ARCHIVE: RootFolder = {role: "Archive", name: "Archive", path: "", attr: new Set(["\\Archive"]), children: []};
|
|
||||||
let JUNK: RootFolder = {role: "Junk", name: "Junk", path: "", attr: new Set(["\\Junk"]), children: []};
|
|
||||||
let TRASH: RootFolder = {role: "Trash", name: "Trash", path: "", attr: new Set(["\\Trash"]), children: []};
|
|
||||||
|
|
||||||
// Setup map to find special folders
|
|
||||||
let ROOT: Map<string, RootFolder> = new Map();
|
|
||||||
ROOT.set("Drafts", DRAFTS);
|
|
||||||
ROOT.set("Sent", SENT);
|
|
||||||
ROOT.set("Archive", ARCHIVE);
|
|
||||||
ROOT.set("Junk", JUNK);
|
|
||||||
ROOT.set("Trash", TRASH);
|
|
||||||
|
|
||||||
let folders: RootFolder[] = [];
|
|
||||||
let inboxOption: string = "*";
|
let inboxOption: string = "*";
|
||||||
let inboxOptions: string[] = [];
|
let inboxOptions: string[] = [];
|
||||||
|
|
||||||
@ -36,183 +23,26 @@
|
|||||||
let messageLookup: Map<string, ImapMessage> = new Map();
|
let messageLookup: Map<string, ImapMessage> = new Map();
|
||||||
window.messageLookup = messageLookup;
|
window.messageLookup = messageLookup;
|
||||||
|
|
||||||
let currentFolder: FolderSelection = {name: "Inbox", obj: INBOX};
|
let currentFolder: MailFolder = fm.INBOX;
|
||||||
let folderMessages: ImapMessage[] = [];
|
let folderMessages: Promise<ImapMessage[]>;
|
||||||
$: folderMessages = messageList.get(currentFolder.obj.path);
|
|
||||||
|
|
||||||
let currentMessage: ImapMessage | null = null;
|
let currentMessage: ImapMessage | null = null;
|
||||||
|
|
||||||
function changeSelectedFolder(p: FolderSelection) {
|
function changeSelectedFolder(p: MailFolder) {
|
||||||
currentFolder = p;
|
currentFolder = p;
|
||||||
grabFolderMessages(p.obj);
|
console.log("changeSelectedFolder", p);
|
||||||
}
|
folderMessages = mm.fetchMessages(p.path, 1, 10, 10);
|
||||||
|
|
||||||
function grabFolderMessages(p: TreeFolder) {
|
|
||||||
let msgs = messageList.get(p.path);
|
|
||||||
if (msgs == undefined) {
|
|
||||||
mainWS.send(JSON.stringify({action: "fetch", args: [p.path, "1", "10", "10"]}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
folderMessages = msgs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$: console.log("Selected", currentFolder);
|
$: console.log("Selected", currentFolder);
|
||||||
|
|
||||||
function countChar(s: string, c: string) {
|
|
||||||
let result = 0;
|
|
||||||
for (let i = 0; i < s.length; i++) if (s[i] == c) result++;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectWS() {
|
|
||||||
mainWS = new WebSocket(import.meta.env.VITE_IMAP_LOTUS);
|
|
||||||
mainWS.addEventListener("open", () => {
|
|
||||||
mainWS.send(JSON.stringify({token: getBearer().slice(7)}));
|
|
||||||
});
|
|
||||||
mainWS.addEventListener("message", e => {
|
|
||||||
let j = JSON.parse(e.data);
|
|
||||||
if (j.auth === "ok") {
|
|
||||||
mainWS.send(JSON.stringify({action: "list", args: ["", "*"]}));
|
|
||||||
}
|
|
||||||
if (j.type === "list") {
|
|
||||||
// === Example output of list command ===
|
|
||||||
// let j = {
|
|
||||||
// type: "list",
|
|
||||||
// value: [
|
|
||||||
// {Attributes: ["\\HasChildren", "\\UnMarked", "\\Archive"], Delimiter: "/", Name: "Archive"},
|
|
||||||
// {Attributes: ["\\HasNoChildren", "\\UnMarked", "\\Junk"], Delimiter: "/", Name: "Junk"},
|
|
||||||
// {Attributes: ["\\HasChildren", "\\Trash"], Delimiter: "/", Name: "Trash"},
|
|
||||||
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/status"},
|
|
||||||
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/hello"},
|
|
||||||
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/hi"},
|
|
||||||
// {Attributes: ["\\Noselect", "\\HasChildren"], Delimiter: "/", Name: "INBOX/sub"},
|
|
||||||
// {Attributes: ["\\HasNoChildren"], Delimiter: "/", Name: "INBOX/sub/folder"},
|
|
||||||
// {Attributes: ["\\HasNoChildren", "\\UnMarked", "\\Drafts"], Delimiter: "/", Name: "Drafts"},
|
|
||||||
// {Attributes: ["\\HasNoChildren", "\\Sent"], Delimiter: "/", Name: "Sent"},
|
|
||||||
// {Attributes: ["\\HasChildren"], Delimiter: "/", Name: "INBOX"},
|
|
||||||
// ],
|
|
||||||
// };
|
|
||||||
|
|
||||||
let imapFolders = j.value as ImapFolder[];
|
|
||||||
|
|
||||||
// Remove no-select folders
|
|
||||||
imapFolders = imapFolders.filter(x => !x.Attributes.includes("\\Noselect"));
|
|
||||||
// Sort shorter paths first so parent folders are registered before children
|
|
||||||
imapFolders = imapFolders.sort((a, b) => countChar(a.Name, a.Delimiter) - countChar(b.Name, b.Delimiter));
|
|
||||||
|
|
||||||
// Store reference to special folders
|
|
||||||
let ROOTKEYS: Map<string, RootFolder> = new Map();
|
|
||||||
|
|
||||||
imapFolders.forEach(x => {
|
|
||||||
// Find inbox folder
|
|
||||||
if (x.Name === "INBOX") {
|
|
||||||
x.Attributes.forEach(x => {
|
|
||||||
INBOX.attr.add(x);
|
|
||||||
});
|
|
||||||
ROOTKEYS.set(x.Name, INBOX);
|
|
||||||
return; // continue imapFolders loop
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test for all special folder attributes
|
|
||||||
for (let [k, v] of ROOT.entries()) {
|
|
||||||
if (x.Attributes.includes("\\" + k)) {
|
|
||||||
v.name = x.Name;
|
|
||||||
x.Attributes.forEach(x => {
|
|
||||||
v.attr.add(x);
|
|
||||||
});
|
|
||||||
v.path = x.Name;
|
|
||||||
// map name to root key
|
|
||||||
ROOTKEYS.set(x.Name, v);
|
|
||||||
return; // continue imapFolders loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let n = x.Name.indexOf(x.Delimiter);
|
|
||||||
if (n == -1) {
|
|
||||||
console.error("No parent folder wtf??", x.Name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let parent = x.Name.slice(0, n);
|
|
||||||
let pObj: TreeFolder | undefined = ROOTKEYS.get(parent);
|
|
||||||
if (pObj == undefined) {
|
|
||||||
console.error("Parent is not a root folder??", x.Name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pIdx = n + 1;
|
|
||||||
|
|
||||||
for (let i = pIdx; i < x.Name.length; i++) {
|
|
||||||
if (x.Name[i] != x.Delimiter) continue;
|
|
||||||
// find child matching current slice
|
|
||||||
let nextObj: TreeFolder | undefined = pObj?.children.find(x2 => {
|
|
||||||
// check if folder matches current slice
|
|
||||||
return x2.name === x.Name.slice(pIdx, i);
|
|
||||||
});
|
|
||||||
// if no slice is found try a bigger slice
|
|
||||||
if (nextObj == undefined) continue;
|
|
||||||
|
|
||||||
// move into child folder
|
|
||||||
pObj = nextObj;
|
|
||||||
pIdx = i + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// no parent was found at all
|
|
||||||
if (pObj == undefined) {
|
|
||||||
console.error("Parent folder does not exist??", x.Name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// add child to current parent
|
|
||||||
pObj.children.push({
|
|
||||||
name: x.Name.slice(pIdx),
|
|
||||||
path: x.Name,
|
|
||||||
attr: new Set(x.Attributes),
|
|
||||||
children: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// output special folders in order
|
|
||||||
folders = [INBOX, DRAFTS, SENT, ARCHIVE, JUNK, TRASH];
|
|
||||||
}
|
|
||||||
if (j.type == "fetch") {
|
|
||||||
// {
|
|
||||||
// type: "fetch",
|
|
||||||
// value: [
|
|
||||||
// {
|
|
||||||
// $Body: {},
|
|
||||||
// BodyStructure: null,
|
|
||||||
// Envelope: {
|
|
||||||
// Date: "2023-09-10T20:54:09-04:00",
|
|
||||||
// Subject: "This is an email subject",
|
|
||||||
// From: [{PersonalName: "A Cool User", AtDomainList: "", MailboxName: "test", HostName: "example.com"}],
|
|
||||||
// Sender: [{PersonalName: "A Cool User", AtDomainList: "", MailboxName: "test", HostName: "example.com"}],
|
|
||||||
// ReplyTo: [{PersonalName: "A Cool User", AtDomainList: "", MailboxName: "test", HostName: "example.com"}],
|
|
||||||
// To: [{PersonalName: "Internal", AtDomainList: "", MailboxName: "melon+hi", HostName: "example.org"}],
|
|
||||||
// Cc: null,
|
|
||||||
// Bcc: null,
|
|
||||||
// InReplyTo: "",
|
|
||||||
// MessageId: "\u003c950124.162336@example.com\u003e",
|
|
||||||
// },
|
|
||||||
// Flags: ["\\Seen", "nonjunk"],
|
|
||||||
// InternalDate: "2023-09-10T20:54:10-04:00",
|
|
||||||
// Items: ["UID", "FLAGS", "INTERNALDATE", "ENVELOPE"],
|
|
||||||
// SeqNum: 1,
|
|
||||||
// Size: 0,
|
|
||||||
// Uid: 18,
|
|
||||||
// }
|
|
||||||
// ]
|
|
||||||
// };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeLoginSession() {
|
function removeLoginSession() {
|
||||||
$loginStore = null;
|
$loginStore = null;
|
||||||
localStorage.removeItem("login-session");
|
localStorage.removeItem("login-session");
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
connectWS();
|
mainWS = new WS(import.meta.env.VITE_IMAP_LOTUS, getBearer().slice(7), fm, mm);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -241,12 +71,16 @@
|
|||||||
<div id="login-view">Please login to continue</div>
|
<div id="login-view">Please login to continue</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div id="sidebar">
|
<div id="sidebar">
|
||||||
{#each folders as folder}
|
{#each fm.ROOT as folder}
|
||||||
<TreePath data={folder} selected={currentFolder.name} on:select={n => changeSelectedFolder(n.detail)} />
|
<TreePath data={folder} selected={currentFolder.name} on:select={n => changeSelectedFolder(n.detail)} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div id="folder-view">
|
<div id="folder-view">
|
||||||
<FolderView folder={currentFolder} messages={folderMessages} />
|
{#await folderMessages}
|
||||||
|
<div>Loading messages</div>
|
||||||
|
{:then x}
|
||||||
|
<FolderView folder={currentFolder} messages={x} />
|
||||||
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
<div id="message-view">
|
<div id="message-view">
|
||||||
<div style="padding:8px;background-color:#bb7900;">Warning: This is currently still under development</div>
|
<div style="padding:8px;background-color:#bb7900;">Warning: This is currently still under development</div>
|
||||||
@ -287,7 +121,9 @@
|
|||||||
height: 70px;
|
height: 70px;
|
||||||
padding: 0 32px;
|
padding: 0 32px;
|
||||||
background-color: #2c2c2c;
|
background-color: #2c2c2c;
|
||||||
box-shadow: 0 4px 8px #0003, 0 6px 20px #00000030;
|
box-shadow:
|
||||||
|
0 4px 8px #0003,
|
||||||
|
0 6px 20px #00000030;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -350,10 +186,20 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#option-view {
|
#folder-view {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
width: 250px;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#message-view {
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -361,7 +207,9 @@
|
|||||||
footer {
|
footer {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background-color: #2c2c2c;
|
background-color: #2c2c2c;
|
||||||
box-shadow: 0 -4px 8px #0003, 0 -6px 20px #00000030;
|
box-shadow:
|
||||||
|
0 -4px 8px #0003,
|
||||||
|
0 -6px 20px #00000030;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {slide} from "svelte/transition";
|
import {slide} from "svelte/transition";
|
||||||
import type {FolderSelection, RootFolder, TreeFolder} from "../types/internal";
|
|
||||||
import {Archive, ChevronRight, Flame, Folder, Inbox, Send, Text, Trash2} from "lucide-svelte";
|
import {Archive, ChevronRight, Flame, Folder, Inbox, Send, Text, Trash2} from "lucide-svelte";
|
||||||
import {createEventDispatcher} from "svelte";
|
import {createEventDispatcher} from "svelte";
|
||||||
|
import type { MailFolder, RootMailFolder } from "../logic/folder";
|
||||||
|
|
||||||
let folderIcons: {[key: string]: any} = {
|
let folderIcons: {[key: string]: any} = {
|
||||||
Inbox: Inbox,
|
Inbox: Inbox,
|
||||||
@ -15,12 +15,12 @@
|
|||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
export let data: TreeFolder;
|
export let data: MailFolder;
|
||||||
let expanded: boolean = false;
|
let expanded: boolean = false;
|
||||||
export let treeOffset: number = 0;
|
export let treeOffset: number = 0;
|
||||||
export let selected: string = "";
|
export let selected: string = "";
|
||||||
|
|
||||||
function isRootFolder(obj: any): obj is RootFolder {
|
function isRootFolder(obj: any): obj is RootMailFolder {
|
||||||
return "role" in obj;
|
return "role" in obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,8 +34,8 @@
|
|||||||
return full.slice(data.name.length + 1);
|
return full.slice(data.name.length + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerSelected(n?: FolderSelection) {
|
function triggerSelected(n?: MailFolder) {
|
||||||
dispatch("select", n == undefined ? {name: data.name, obj: data} : n);
|
dispatch("select", n == undefined ? data : n);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -53,7 +53,7 @@
|
|||||||
data={child}
|
data={child}
|
||||||
treeOffset={treeOffset + 1}
|
treeOffset={treeOffset + 1}
|
||||||
selected={getNextSelected(selected)}
|
selected={getNextSelected(selected)}
|
||||||
on:select={n => triggerSelected({name: data.name + "/" + n.detail.name, obj: n.detail.obj})}
|
on:select={n => triggerSelected(n.detail)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
115
src/logic/folder.ts
Normal file
115
src/logic/folder.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import type {ImapFolder} from "../types/imap";
|
||||||
|
|
||||||
|
export class MailFolder {
|
||||||
|
name: string; // Local name
|
||||||
|
path: string; // Global path
|
||||||
|
attr: Set<string>;
|
||||||
|
children: Array<MailFolder>;
|
||||||
|
|
||||||
|
constructor(name: string, path: string, attr: Set<string>) {
|
||||||
|
this.name = name;
|
||||||
|
this.path = path;
|
||||||
|
this.attr = attr;
|
||||||
|
this.children = new Array();
|
||||||
|
}
|
||||||
|
|
||||||
|
addAttrs(a: string[]) {
|
||||||
|
a.forEach(x => this.attr.add(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
addChild(f: MailFolder) {
|
||||||
|
this.children.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RootMailFolder extends MailFolder {
|
||||||
|
role: string;
|
||||||
|
|
||||||
|
constructor(name: string, role: string, path: string, attr: Set<string>) {
|
||||||
|
super(name, path, attr);
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FolderManager {
|
||||||
|
public INBOX: RootMailFolder = new RootMailFolder("Inbox", "Inbox", "INBOX", new Set(""));
|
||||||
|
public DRAFTS: RootMailFolder = new RootMailFolder("Drafts", "Drafts", "~", new Set(["\\Drafts"]));
|
||||||
|
public SENT: RootMailFolder = new RootMailFolder("Sent", "Sent", "~", new Set(["\\Sent"]));
|
||||||
|
public ARCHIVE: RootMailFolder = new RootMailFolder("Archive", "Archive", "~", new Set(["\\Archive"]));
|
||||||
|
public JUNK: RootMailFolder = new RootMailFolder("Junk", "Junk", "~", new Set(["\\Junk"]));
|
||||||
|
public TRASH: RootMailFolder = new RootMailFolder("Trash", "Trash", "~", new Set(["\\Trash"]));
|
||||||
|
|
||||||
|
public ROOT = [this.INBOX, this.DRAFTS, this.SENT, this.ARCHIVE, this.JUNK, this.TRASH];
|
||||||
|
private ROOT_SPECIAL = {
|
||||||
|
Drafts: this.DRAFTS,
|
||||||
|
Sent: this.SENT,
|
||||||
|
Archive: this.ARCHIVE,
|
||||||
|
Junk: this.JUNK,
|
||||||
|
Trash: this.TRASH,
|
||||||
|
};
|
||||||
|
// Store reference to special folders
|
||||||
|
private ROOT_PATHS: Map<string, RootMailFolder> = new Map();
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
resolveFolder(x: ImapFolder) {
|
||||||
|
// Find inbox folder
|
||||||
|
if (x.Name === "INBOX") {
|
||||||
|
this.INBOX.addAttrs(x.Attributes);
|
||||||
|
this.ROOT_PATHS.set(x.Name, this.INBOX);
|
||||||
|
return; // continue imapFolders loop
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test for all special folder attributes
|
||||||
|
for (let [k, v] of Object.entries(this.ROOT_SPECIAL)) {
|
||||||
|
if (x.Attributes.includes("\\" + k)) {
|
||||||
|
v.name = x.Name;
|
||||||
|
x.Attributes.forEach(x => {
|
||||||
|
v.attr.add(x);
|
||||||
|
});
|
||||||
|
v.path = x.Name;
|
||||||
|
// map name to root key
|
||||||
|
this.ROOT_PATHS.set(x.Name, v);
|
||||||
|
return; // continue imapFolders loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = x.Name.indexOf(x.Delimiter);
|
||||||
|
if (n == -1) {
|
||||||
|
console.error("No parent folder wtf??", x.Name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let parent = x.Name.slice(0, n);
|
||||||
|
let pObj: MailFolder | undefined = this.ROOT_PATHS.get(parent);
|
||||||
|
if (pObj == undefined) {
|
||||||
|
console.error("Parent is not a root folder??", x.Name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pIdx = n + 1;
|
||||||
|
|
||||||
|
for (let i = pIdx; i < x.Name.length; i++) {
|
||||||
|
if (x.Name[i] != x.Delimiter) continue;
|
||||||
|
// find child matching current slice
|
||||||
|
let nextObj: MailFolder | undefined = pObj?.children.find(x2 => {
|
||||||
|
// check if folder matches current slice
|
||||||
|
return x2.name === x.Name.slice(pIdx, i);
|
||||||
|
});
|
||||||
|
// if no slice is found try a bigger slice
|
||||||
|
if (nextObj == undefined) continue;
|
||||||
|
|
||||||
|
// move into child folder
|
||||||
|
pObj = nextObj;
|
||||||
|
pIdx = i + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no parent was found at all
|
||||||
|
if (pObj == undefined) {
|
||||||
|
console.error("Parent folder does not exist??", x.Name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// add child to current parent
|
||||||
|
pObj.children.push(new MailFolder(x.Name.slice(pIdx), x.Name, new Set(x.Attributes)));
|
||||||
|
}
|
||||||
|
}
|
83
src/logic/message.ts
Normal file
83
src/logic/message.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import type {ImapEnvelope, ImapMessage} from "../types/imap";
|
||||||
|
import {in2mins} from "../utils/in2mins";
|
||||||
|
import type {WS} from "./ws";
|
||||||
|
|
||||||
|
export class MailMessage {
|
||||||
|
expires: Date;
|
||||||
|
__Body: unknown;
|
||||||
|
Envelope: ImapEnvelope;
|
||||||
|
Flags: Set<string> = new Set();
|
||||||
|
InternalDate: Date;
|
||||||
|
Items: string[];
|
||||||
|
SeqNum: number;
|
||||||
|
Size: number;
|
||||||
|
Uid: number;
|
||||||
|
|
||||||
|
constructor(m: ImapMessage) {
|
||||||
|
this.expires = in2mins();
|
||||||
|
this.__Body = m.$Body;
|
||||||
|
this.Envelope = m.Envelope;
|
||||||
|
m.Flags.forEach(x => this.Flags.add(x));
|
||||||
|
this.InternalDate = new Date(m.InternalDate);
|
||||||
|
this.Items = m.Items;
|
||||||
|
this.SeqNum = m.SeqNum;
|
||||||
|
this.Size = m.Size;
|
||||||
|
this.Uid = m.Uid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MessageManager {
|
||||||
|
ws?: WS;
|
||||||
|
fetchCalls: Map<number, {path: string; trigger: (msgs: MailMessage[]) => void}> = new Map(); // key = unique
|
||||||
|
store: Map<string, FolderMessageManager> = new Map(); // key = path -> seqnum
|
||||||
|
|
||||||
|
setWS(ws: WS) {
|
||||||
|
this.ws = ws;
|
||||||
|
window.__mm_store = this.store;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchMessages(path: string, start: number, end: number, limit: number): Promise<MailMessage[]> {
|
||||||
|
let now = Date.now();
|
||||||
|
let s = this.store.get(path);
|
||||||
|
if (s?.expires || 0 < now) {
|
||||||
|
return new Promise((res, rej) => {
|
||||||
|
this.fetchCalls.set(now, {
|
||||||
|
path: path,
|
||||||
|
trigger: (msgs: MailMessage[]) => {
|
||||||
|
console.log("Fetch call triggered");
|
||||||
|
res(msgs);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.ws?.send("fetch", {sync: now, path, start, end, limit});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve([...(s?.store.values() || [])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchFolder(path: string): Promise<MailMessage[]> {
|
||||||
|
return this.fetchMessages(path, 1, 100, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMessage(sync: number, m: MailMessage[]) {
|
||||||
|
let call = this.fetchCalls.get(sync);
|
||||||
|
if (!call) return;
|
||||||
|
this.fetchCalls.delete(sync);
|
||||||
|
let s = this.store.get(call.path);
|
||||||
|
if (!s) {
|
||||||
|
s = new FolderMessageManager();
|
||||||
|
this.store.set(call.path, s);
|
||||||
|
}
|
||||||
|
s.saveIncomingMessages(m);
|
||||||
|
call.trigger([...s.store.values()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FolderMessageManager {
|
||||||
|
expires: Date = in2mins();
|
||||||
|
store: Map<number, MailMessage> = new Map(); // key = seqnum
|
||||||
|
|
||||||
|
saveIncomingMessages(m: MailMessage[]) {
|
||||||
|
m.forEach(x => this.store.set(x.SeqNum, x));
|
||||||
|
}
|
||||||
|
}
|
100
src/logic/ws.ts
Normal file
100
src/logic/ws.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import type {ImapFolder, ImapMessage} from "../types/imap";
|
||||||
|
import {countChar} from "../utils/count-char";
|
||||||
|
import type {FolderManager, RootMailFolder} from "./folder";
|
||||||
|
import {MailMessage, type MessageManager} from "./message";
|
||||||
|
|
||||||
|
export class WS {
|
||||||
|
ws: WebSocket;
|
||||||
|
fm: FolderManager;
|
||||||
|
mm: MessageManager;
|
||||||
|
|
||||||
|
constructor(address: string, token: string, fm: FolderManager, mm: MessageManager) {
|
||||||
|
this.ws = new WebSocket(address);
|
||||||
|
this.ws.addEventListener("open", () => {
|
||||||
|
this.ws.send(JSON.stringify({token: token}));
|
||||||
|
});
|
||||||
|
this.ws.addEventListener("message", e => {
|
||||||
|
this.handleMessage(e);
|
||||||
|
});
|
||||||
|
this.fm = fm;
|
||||||
|
this.mm = mm;
|
||||||
|
mm.setWS(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(action: string, args: any) {
|
||||||
|
this.ws.send(JSON.stringify({action, args}));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(e: MessageEvent<any>) {
|
||||||
|
let j = JSON.parse(e.data);
|
||||||
|
if (j.auth === "ok") {
|
||||||
|
this.ws.send(JSON.stringify({action: "list", args: ["", "*"]}));
|
||||||
|
}
|
||||||
|
if (j.type === "list") {
|
||||||
|
// === Example output of list command ===
|
||||||
|
// let j = {
|
||||||
|
// type: "list",
|
||||||
|
// value: [
|
||||||
|
// {Attributes: ["\\HasChildren", "\\UnMarked", "\\Archive"], Delimiter: "/", Name: "Archive"},
|
||||||
|
// {Attributes: ["\\HasNoChildren", "\\UnMarked", "\\Junk"], Delimiter: "/", Name: "Junk"},
|
||||||
|
// {Attributes: ["\\HasChildren", "\\Trash"], Delimiter: "/", Name: "Trash"},
|
||||||
|
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/status"},
|
||||||
|
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/hello"},
|
||||||
|
// {Attributes: ["\\HasNoChildren", "\\UnMarked"], Delimiter: "/", Name: "INBOX/hi"},
|
||||||
|
// {Attributes: ["\\Noselect", "\\HasChildren"], Delimiter: "/", Name: "INBOX/sub"},
|
||||||
|
// {Attributes: ["\\HasNoChildren"], Delimiter: "/", Name: "INBOX/sub/folder"},
|
||||||
|
// {Attributes: ["\\HasNoChildren", "\\UnMarked", "\\Drafts"], Delimiter: "/", Name: "Drafts"},
|
||||||
|
// {Attributes: ["\\HasNoChildren", "\\Sent"], Delimiter: "/", Name: "Sent"},
|
||||||
|
// {Attributes: ["\\HasChildren"], Delimiter: "/", Name: "INBOX"},
|
||||||
|
// ],
|
||||||
|
// };
|
||||||
|
|
||||||
|
let imapFolders = j.value as ImapFolder[];
|
||||||
|
|
||||||
|
// Remove no-select folders
|
||||||
|
imapFolders = imapFolders.filter(x => !x.Attributes.includes("\\Noselect"));
|
||||||
|
// Sort shorter paths first so parent folders are registered before children
|
||||||
|
imapFolders = imapFolders.sort((a, b) => countChar(a.Name, a.Delimiter) - countChar(b.Name, b.Delimiter));
|
||||||
|
|
||||||
|
imapFolders.forEach(x => {
|
||||||
|
this.fm.resolveFolder(x);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (j.type == "fetch") {
|
||||||
|
// {
|
||||||
|
// type: "fetch",
|
||||||
|
// sync: 0,
|
||||||
|
// value: [
|
||||||
|
// {
|
||||||
|
// $Body: {},
|
||||||
|
// BodyStructure: null,
|
||||||
|
// Envelope: {
|
||||||
|
// Date: "2023-09-10T20:54:09-04:00",
|
||||||
|
// Subject: "This is an email subject",
|
||||||
|
// From: [{PersonalName: "A Cool User", AtDomainList: "", MailboxName: "test", HostName: "example.com"}],
|
||||||
|
// Sender: [{PersonalName: "A Cool User", AtDomainList: "", MailboxName: "test", HostName: "example.com"}],
|
||||||
|
// ReplyTo: [{PersonalName: "A Cool User", AtDomainList: "", MailboxName: "test", HostName: "example.com"}],
|
||||||
|
// To: [{PersonalName: "Internal", AtDomainList: "", MailboxName: "melon+hi", HostName: "example.org"}],
|
||||||
|
// Cc: null,
|
||||||
|
// Bcc: null,
|
||||||
|
// InReplyTo: "",
|
||||||
|
// MessageId: "\u003c950124.162336@example.com\u003e",
|
||||||
|
// },
|
||||||
|
// Flags: ["\\Seen", "nonjunk"],
|
||||||
|
// InternalDate: "2023-09-10T20:54:10-04:00",
|
||||||
|
// Items: ["UID", "FLAGS", "INTERNALDATE", "ENVELOPE"],
|
||||||
|
// SeqNum: 1,
|
||||||
|
// Size: 0,
|
||||||
|
// Uid: 18,
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
// };
|
||||||
|
|
||||||
|
let imapMsg = j.value as ImapMessage[];
|
||||||
|
this.mm.updateMessage(
|
||||||
|
j.sync,
|
||||||
|
imapMsg.map(x => new MailMessage(x)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,15 +0,0 @@
|
|||||||
export interface TreeFolder {
|
|
||||||
name: string; // Local name
|
|
||||||
path: string; // Global path
|
|
||||||
attr: Set<string>;
|
|
||||||
children: TreeFolder[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RootFolder extends TreeFolder {
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FolderSelection {
|
|
||||||
name: string;
|
|
||||||
obj: TreeFolder;
|
|
||||||
}
|
|
5
src/utils/count-char.ts
Normal file
5
src/utils/count-char.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export function countChar(s: string, c: string) {
|
||||||
|
let result = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) if (s[i] == c) result++;
|
||||||
|
return result;
|
||||||
|
}
|
5
src/utils/in2mins.ts
Normal file
5
src/utils/in2mins.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export function in2mins(): Date {
|
||||||
|
let future = new Date();
|
||||||
|
future.setMinutes(future.getMinutes() + 2);
|
||||||
|
return future;
|
||||||
|
}
|
@ -160,6 +160,7 @@ func apiServer(verify mjwt.Verifier) {
|
|||||||
c.WriteMessage(websocket.TextMessage, []byte(`{"auth":"ok"}`))
|
c.WriteMessage(websocket.TextMessage, []byte(`{"auth":"ok"}`))
|
||||||
continue
|
continue
|
||||||
} else if vAct, ok := m["action"]; ok {
|
} else if vAct, ok := m["action"]; ok {
|
||||||
|
args := m["args"]
|
||||||
switch vAct.(string) {
|
switch vAct.(string) {
|
||||||
case "list":
|
case "list":
|
||||||
log.Println(m)
|
log.Println(m)
|
||||||
@ -188,6 +189,37 @@ func apiServer(verify mjwt.Verifier) {
|
|||||||
`))
|
`))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
case "fetch":
|
||||||
|
c.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf(`
|
||||||
|
{
|
||||||
|
"type": "fetch",
|
||||||
|
"sync": %f,
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"$Body": {},
|
||||||
|
"BodyStructure": null,
|
||||||
|
"Envelope": {
|
||||||
|
"Date": "2023-09-10T20:54:09-04:00",
|
||||||
|
"Subject": "This is an email subject",
|
||||||
|
"From": [{"PersonalName": "A Cool User", "AtDomainList": "", "MailboxName": "test", "HostName": "example.com"}],
|
||||||
|
"Sender": [{"PersonalName": "A Cool User", "AtDomainList": "", "MailboxName": "test", "HostName": "example.com"}],
|
||||||
|
"ReplyTo": [{"PersonalName": "A Cool User", "AtDomainList": "", "MailboxName": "test", "HostName": "example.com"}],
|
||||||
|
"To": [{"PersonalName": "Internal", "AtDomainList": "", "MailboxName": "melon+hi", "HostName": "example.org"}],
|
||||||
|
"Cc": null,
|
||||||
|
"Bcc": null,
|
||||||
|
"InReplyTo": "",
|
||||||
|
"MessageId": "\u003c950124.162336@example.com\u003e"
|
||||||
|
},
|
||||||
|
"Flags": ["\\Seen", "nonjunk"],
|
||||||
|
"InternalDate": "2023-09-10T20:54:10-04:00",
|
||||||
|
"Items": ["UID", "FLAGS", "INTERNALDATE", "ENVELOPE"],
|
||||||
|
"SeqNum": 1,
|
||||||
|
"Size": 0,
|
||||||
|
"Uid": 18
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
`, args.(map[string]any)["sync"])))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user