From a412b6a0a4c4f1ad95e36c8884d2c82d05a7bb6c Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Tue, 3 Sep 2024 08:36:28 -0700 Subject: [PATCH 1/4] Display the 'sentAt' information. --- src/EmailContent.tsx | 4 ++++ src/client/jmap-client-ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/EmailContent.tsx b/src/EmailContent.tsx index 953d043..e1df3c4 100644 --- a/src/EmailContent.tsx +++ b/src/EmailContent.tsx @@ -88,6 +88,10 @@ class EmailContent extends React.Component< Received {email.receivedAt} + + Sent + + {email.sentAt} diff --git a/src/client/jmap-client-ts b/src/client/jmap-client-ts index fdab379..d33318d 160000 --- a/src/client/jmap-client-ts +++ b/src/client/jmap-client-ts @@ -1 +1 @@ -Subproject commit fdab37996a4709b13962b67d1aa49e85a8305755 +Subproject commit d33318d3e1f7e838d9e9b828f233b98f6b21d88f From 5a36e46da552bb731e7bbddf12758e6db50e3f35 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Tue, 3 Sep 2024 10:09:44 -0700 Subject: [PATCH 2/4] Add support for react-bootstrap-icons. --- package-lock.json | 12 ++++++++++++ package.json | 1 + 2 files changed, 13 insertions(+) diff --git a/package-lock.json b/package-lock.json index 8121cc1..74754c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "bootstrap": "^5.3.3", "react": "^18.3.1", "react-bootstrap": "^2.10.4", + "react-bootstrap-icons": "^1.11.4", "react-dom": "^18.3.1", "react-scripts": "5.0.1", "sass": "^1.77.8", @@ -14746,6 +14747,17 @@ } } }, + "node_modules/react-bootstrap-icons": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/react-bootstrap-icons/-/react-bootstrap-icons-1.11.4.tgz", + "integrity": "sha512-lnkOpNEZ/Zr7mNxvjA9efuarCPSgtOuGA55XiRj7ASJnBjb1wEAdtJOd2Aiv9t07r7FLI1IgyZPg9P6jqWD/IA==", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": ">=16.8.6" + } + }, "node_modules/react-dev-utils": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", diff --git a/package.json b/package.json index 6627f40..bf024bc 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "bootstrap": "^5.3.3", "react": "^18.3.1", "react-bootstrap": "^2.10.4", + "react-bootstrap-icons": "^1.11.4", "react-dom": "^18.3.1", "react-scripts": "5.0.1", "sass": "^1.77.8", From dfca32eb364f3a58e91cbe491492894862b80571 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Tue, 3 Sep 2024 10:11:02 -0700 Subject: [PATCH 3/4] Initial integration of event stream. This doesn't do much yet but log the events I'm getting until I understand them better. --- src/client/Client.tsx | 16 ++++++++++++++++ src/client/jmap-client-ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/client/Client.tsx b/src/client/Client.tsx index 4ed9de5..b85c74d 100644 --- a/src/client/Client.tsx +++ b/src/client/Client.tsx @@ -263,6 +263,10 @@ export default class Client { if (!this.jclient) return; const session = this.jclient.getSession(); + // Subscribe to server-pushed events + if (session.eventSourceUrl) { + this._subscribeToEventSource(session.eventSourceUrl); + } this.state.session = { ...session, accounts: Object.fromEntries( @@ -277,4 +281,16 @@ export default class Client { if (!this.state.session) return; this._triggerChange("Session"); } + + _subscribeToEventSource(url: string) { + // For typechecker + if (this.jclient === null) return; + const eventSourceUrl = url + .replace("{types}", "*") + .replace("{closeafter}", "no") + .replace("{ping}", "60"); + this.jclient.subscribeToEvents(eventSourceUrl, (e) => { + console.log("Got an event!", e); + }); + } } diff --git a/src/client/jmap-client-ts b/src/client/jmap-client-ts index d33318d..2ef5f5b 160000 --- a/src/client/jmap-client-ts +++ b/src/client/jmap-client-ts @@ -1 +1 @@ -Subproject commit d33318d3e1f7e838d9e9b828f233b98f6b21d88f +Subproject commit 2ef5f5b7fa0a22a499bd32831ac24622f17e10e6 From 1e9dae15f13dc81288c438acd436b87c08610db1 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Tue, 3 Sep 2024 12:03:04 -0700 Subject: [PATCH 4/4] Add button to move an email to trash. This is the first time I'm modifying data instead of just displaying it. And this commit is a mess, it's all over the place with duplicating types and breaking my class layers. But it works, technically, so, whatever, checkpoint! I need to totally start reworking the base client library as I'm not happy with it at all. Also, it turns out that we have very little type protection on the "set" methods. I had a totally improper signature for about an hour that led to useless debugging. Reading the standard, which is excellent, helped me get sorted out, but they type checker should be helping me. Additionally, I should be creating this Account class type within the client. --- src/EmailSummary.tsx | 19 +++++++++ src/client/Client.tsx | 89 +++++++++++++++++++++++++++++++++++++++++-- src/client/types.tsx | 16 +++++++- 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/src/EmailSummary.tsx b/src/EmailSummary.tsx index a9514ea..66d3517 100644 --- a/src/EmailSummary.tsx +++ b/src/EmailSummary.tsx @@ -1,3 +1,7 @@ +import { Trash } from "react-bootstrap-icons"; +import Button from "react-bootstrap/Button"; +import ButtonGroup from "react-bootstrap/ButtonGroup"; +import ButtonToolbar from "react-bootstrap/ButtonToolbar"; import Placeholder from "react-bootstrap/Placeholder"; import React from "react"; @@ -55,6 +59,21 @@ class EmailSummary extends React.Component< stub.subject} + + + + + + ); } diff --git a/src/client/Client.tsx b/src/client/Client.tsx index b85c74d..25eb9ab 100644 --- a/src/client/Client.tsx +++ b/src/client/Client.tsx @@ -3,10 +3,19 @@ * None of the dependencies should leak, in types or otherwise */ import * as base64 from "base-64"; +import * as jmaptypes from "./jmap-client-ts/src/types"; import * as jmapclient from "./jmap-client-ts/src"; import { FetchTransport } from "./jmap-client-ts/src/utils/fetch-transport"; -import { IAccount, IEmail, IEmailStub, IMailbox, ISession } from "./types"; +import { + IAccount, + IEmail, + IEmailStub, + IMailbox, + ISession, + MailboxRole, + PushMessage, +} from "./types"; type Callback = () => void; @@ -21,9 +30,40 @@ export interface ClientState { emailStubs: Set; mailboxes: Set; }; + mailboxes: { + trash: IMailbox; + } | null; session: ISession | null; } +class Account implements IAccount { + id: string; + + accountCapabilities: { [key: string]: jmaptypes.IMailCapabilities }; + isPersonal: boolean; + isReadOnly: boolean; + mailboxes: Array | null; + name: string; + + constructor(id: string, props: jmaptypes.IAccount) { + this.id = id; + this.accountCapabilities = props.accountCapabilities; + this.isPersonal = props.isPersonal; + this.isReadOnly = props.isReadOnly; + this.mailboxes = null; + this.name = props.name; + } + mailboxByRole(role: MailboxRole): IMailbox | null { + if (this.mailboxes === null) return null; + for (let i = 0; i < this.mailboxes.length; i++) { + const m = this.mailboxes[i]; + if (m.role === role) { + return m; + } + } + return null; + } +} export default class Client { // All objects which currently are listening for changes callbacks: Array = []; @@ -34,6 +74,7 @@ export default class Client { emailStubs: new Set(), mailboxes: new Set(), }, + mailboxes: null, session: null, }; @@ -67,6 +108,43 @@ export default class Client { return; } + emailMoveTrash(account: IAccount, emailId: string) { + if (this.jclient === null) return; + console.log("Trashing", emailId); + const email = this.emailStub(emailId); + if (email === null) return; + const trashMailbox = account.mailboxByRole("trash"); + if (trashMailbox === null) { + console.error( + "Cannot trash ", + emailId, + " because ", + account.id, + " does not have a 'trash' mailbox", + ); + return; + } + let mailboxIds = Object.keys(email.mailboxIds).reduce( + (acc, key) => { + acc[key as keyof typeof email.mailboxIds] = false; + return acc; + }, + {} as Record, + ); + mailboxIds[trashMailbox.id] = true; + const props = { + accountId: account.id, + update: { + [email.id]: { + mailboxIds: mailboxIds, + }, + }, + }; + this.jclient.email_set(props).then((response) => { + console.log("Trashed", emailId); + }); + } + // Ensure we have the full email content ensureEmailContent(accountId: string, emailId: string) { if (this.state.session == null) return; @@ -98,7 +176,9 @@ export default class Client { email(emailId: string): IEmail | null { if (this.state.session == null) return null; - return this.state.session.emails[emailId]; + const result = this.state.session.emails[emailId]; + if (result === undefined) return null; + return result; } emailGetContent(accountId: string, emailId: string) { @@ -148,7 +228,7 @@ export default class Client { .email_get({ accountId: accountId, ids: [emailId], - properties: ["from", "receivedAt", "subject"], + properties: ["from", "mailboxIds", "receivedAt", "subject"], }) .then((response) => { console.log(msg, "response", response); @@ -163,6 +243,7 @@ export default class Client { this.state.session.emailStubs[e.id] = { from: e.from, id: e.id, + mailboxIds: e.mailboxIds, receivedAt: e.receivedAt, subject: e.subject, }; @@ -272,7 +353,7 @@ export default class Client { accounts: Object.fromEntries( Object.entries(session.accounts).map(([key, account]) => [ key, - { ...account, id: key.toString(), mailboxes: null }, + new Account(key.toString(), account), ]), ), emails: {}, diff --git a/src/client/types.tsx b/src/client/types.tsx index a479d70..e8fa48c 100644 --- a/src/client/types.tsx +++ b/src/client/types.tsx @@ -1,8 +1,10 @@ import * as client from "./jmap-client-ts/src/types"; +export type MailboxIdMap = { [mailboxId: string]: boolean }; export interface IEmailStub { from: Array | null; id: string; + mailboxIds: MailboxIdMap; receivedAt: string; subject: string; } @@ -18,8 +20,20 @@ export interface IEmail extends client.IEmailProperties { export interface IAccount extends client.IAccount { id: string; mailboxes: Array | null; -} + mailboxByRole(role: MailboxRole): IMailbox | null; +} +export type MailboxRole = + | "all" + | "archive" + | "drafts" + | "flagged" + | "important" + | "junk" + | "sent" + | "subscribed" + | "trash"; +export type PushMessage = client.PushMessage; export type AccountIdMap = { [accountId: string]: IAccount }; export type EmailStubIdMap = { [emailId: string]: IEmailStub }; export type EmailIdMap = { [emailId: string]: IEmail };