From a34d8f53b33da0bac9c2e17ad864d40bf4d52be9 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Wed, 28 Aug 2024 09:21:31 -0700 Subject: [PATCH 01/18] Show email subject lines. This includes a bunch of new things. I've introduced "ensureEmail..." to indicate that the UI would like some data to be populated, but if it is already present we don't need to do anything. I've also introduced a cache for emails that is keyed on the email ID. I don't know if email IDs are unique. They look like they should be globally unique within a given server, but I'm not sure and the standard is unclear. It'll need some experimentation. --- src/EmailList.tsx | 27 ++++++++------------ src/EmailSummary.tsx | 40 +++++++++++++++++++++++++++++ src/client/Client.tsx | 59 ++++++++++++++++++++++++++++++++++++++----- src/client/types.tsx | 4 +++ 4 files changed, 107 insertions(+), 23 deletions(-) create mode 100644 src/EmailSummary.tsx diff --git a/src/EmailList.tsx b/src/EmailList.tsx index 34c44be..4f5a20e 100644 --- a/src/EmailList.tsx +++ b/src/EmailList.tsx @@ -3,6 +3,7 @@ import Stack from "react-bootstrap/Stack"; import Client from "./client/Client"; import { IAccount, IMailbox } from "./client/types"; +import EmailSummary from "./EmailSummary"; type EmailListProps = { account: IAccount | null; @@ -17,37 +18,31 @@ class EmailList extends React.Component { if (this.props.account == null) return; if (this.props.client == null) return; if (this.props.mailbox == null) return; - this.props.client.emailList( + this.props.client.ensureEmailList( this.props.account.id, this.props.mailbox.id, - [], ); } render() { if ( this.props.account == null || + this.props.client == null || this.props.mailbox == null || this.props.mailbox.emailIds == null ) { return ; - } else if (this.props.mailbox.emails == null) { - return ( - - {this.props.mailbox.emailIds.map((e) => ( -
- Email {e} -
- ))} -
- ); } else { return ( - {this.props.mailbox.emails.map((m) => ( -
- {m.subject} -
+ {this.props.mailbox.emailIds.slice(0, 5).map((e) => ( + ))}
); diff --git a/src/EmailSummary.tsx b/src/EmailSummary.tsx new file mode 100644 index 0000000..afffa7a --- /dev/null +++ b/src/EmailSummary.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import Client from "./client/Client"; +import { IAccount, IEmail } from "./client/types"; + +type EmailSummaryProps = { + account: IAccount | null; + client: Client | null; + email: IEmail | null; + emailId: string; +}; +type EmailSummaryState = {}; + +class EmailSummary extends React.Component< + EmailSummaryProps, + EmailSummaryState +> { + componentDidMount() { + this.ensureData(); + } + componentDidUpdate() { + this.ensureData(); + } + + ensureData() { + if (this.props.account == null) return; + if (this.props.client == null) return; + this.props.client.ensureEmailGet(this.props.account.id, this.props.emailId); + } + + render() { + return ( +
+ {this.props.email != null + ? this.props.email.subject + : this.props.emailId} +
+ ); + } +} +export default EmailSummary; diff --git a/src/client/Client.tsx b/src/client/Client.tsx index d002d1c..fde5d63 100644 --- a/src/client/Client.tsx +++ b/src/client/Client.tsx @@ -6,7 +6,7 @@ import * as base64 from "base-64"; import * as jmapclient from "jmap-client-ts"; import { FetchTransport } from "jmap-client-ts/lib/utils/fetch-transport"; -import { IAccount, IMailbox, ISession } from "./types"; +import { IAccount, IEmail, IMailbox, ISession } from "./types"; type Callback = () => void; @@ -36,11 +36,27 @@ export default class Client { this.callbacks.push(f); } + // Ensure we have the fully-fleshed email + ensureEmailGet(accountId: string, emailId: string) { + if (this.state.session == null) return; + const existing = this.state.session.emails[emailId]; + if (existing != null) return; + this.emailGet(accountId, emailId); + } + + // Ensure we have the list of emails for the provided account and mailbox + ensureEmailList(accountId: string, mailboxId: string) { + const mailbox = this.mailbox(accountId, mailboxId); + if (mailbox != null && mailbox.emailIds != null) return; + this.emailList(accountId, mailboxId, []); + } + // Ensure that login happens. If this is called many times, only login once. ensureLogin(auth: IAuth) { if (this.jclient != null) return; this.doLogin(auth); } + // Make the request to get system metadata doLogin(auth: IAuth) { const domain = auth.email.split("@")[1]; @@ -65,13 +81,41 @@ export default class Client { return; } + email(emailId: string): IEmail | null { + if (this.state.session == null) return null; + return this.state.session.emails[emailId]; + } + + emailGet(accountId: string, emailId: string) { + if (this.jclient == null) return; + + this.jclient + .email_get({ + accountId: accountId, + ids: [emailId], + properties: ["mailboxIds", "subject"], + }) + .then((response) => { + console.log("Email response", response); + response.list.forEach((e) => { + if (this.state.session == null) return; + const existing = this.state.session.emails[e.id]; + if (existing !== undefined && existing.subject !== e.subject) { + console.error( + "Expectations violation: email ID is not unique within the server!", + ); + } + this.state.session.emails[e.id] = e; + this._triggerChange(); + }); + }) + .catch((x) => { + console.error("Failed to get email", emailId, x); + }); + } + emailList(accountId: string, mailboxId: string, ids: Array) { if (this.jclient == null) return; - /*this.jclient.email_get({ - accountId: accountId, - ids: [], - properties: ["threadId"] - });*/ this.jclient .email_query({ accountId: accountId, @@ -84,7 +128,7 @@ export default class Client { this._triggerChange(); }) .catch(() => { - console.error("OH NOES"); + console.error("Failed to get email list from mailbox", mailboxId); }); } @@ -145,6 +189,7 @@ export default class Client { { ...account, id: key.toString(), mailboxes: null }, ]), ), + emails: {}, }; if (!this.state.session) return; this._triggerChange(); diff --git a/src/client/types.tsx b/src/client/types.tsx index a4a6b3f..08b9cf9 100644 --- a/src/client/types.tsx +++ b/src/client/types.tsx @@ -5,13 +5,17 @@ export interface IMailbox extends client.IMailboxProperties { emails: Array | null; } +export interface IEmail extends client.IEmailProperties {} + export interface IAccount extends client.IAccount { id: string; mailboxes: Array | null; } export type AccountIdMap = { [accountId: string]: IAccount }; +export type EmailIdMap = { [emailId: string]: IEmail }; export interface ISession extends client.ISession { accounts: AccountIdMap; + emails: EmailIdMap; } From 0d2e43ae832193c8b61536759608045f42f0c0b9 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Wed, 28 Aug 2024 09:38:23 -0700 Subject: [PATCH 02/18] Add ability to support custom styles Not using it yet, just thinking about usability and design a bit. --- package-lock.json | 22 ++++++++++++++++++++++ package.json | 1 + src/App.tsx | 2 +- src/style.scss | 1 + 4 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 src/style.scss diff --git a/package-lock.json b/package-lock.json index 89f11c5..7a9dc9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "react-bootstrap": "^2.10.4", "react-dom": "^18.3.1", "react-scripts": "5.0.1", + "sass": "^1.77.8", "typescript": "^4.9.5", "web-vitals": "^2.1.4" }, @@ -9319,6 +9320,11 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/immutable": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -15524,6 +15530,22 @@ "resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz", "integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==" }, + "node_modules/sass": { + "version": "1.77.8", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz", + "integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==", + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/sass-loader": { "version": "12.6.0", "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", diff --git a/package.json b/package.json index 9247a49..a564594 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "react-bootstrap": "^2.10.4", "react-dom": "^18.3.1", "react-scripts": "5.0.1", + "sass": "^1.77.8", "typescript": "^4.9.5", "web-vitals": "^2.1.4" }, diff --git a/src/App.tsx b/src/App.tsx index a76caf9..ee36fdc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import "./App.css"; -import "bootstrap/dist/css/bootstrap.min.css"; +import "./style.scss"; import Client, { IAuth } from "./client/Client"; import { AccountIdMap, IAccount, IMailbox } from "./client/types"; diff --git a/src/style.scss b/src/style.scss new file mode 100644 index 0000000..5de3350 --- /dev/null +++ b/src/style.scss @@ -0,0 +1 @@ +@import '~bootstrap/scss/bootstrap'; From e68a21dcc440c12b42ee0bbfd62251536c6c5bad Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Wed, 28 Aug 2024 09:38:50 -0700 Subject: [PATCH 03/18] Move email list text to the left, fill the viewport, add border. Much prettier. --- src/AppLayout.tsx | 2 +- src/EmailList.tsx | 2 +- src/EmailSummary.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AppLayout.tsx b/src/AppLayout.tsx index 3ec1e2d..d6d6b41 100644 --- a/src/AppLayout.tsx +++ b/src/AppLayout.tsx @@ -19,7 +19,7 @@ type TopProps = { const AppLayout: React.FC = (props) => { return ( - + diff --git a/src/EmailList.tsx b/src/EmailList.tsx index 4f5a20e..7ff49b4 100644 --- a/src/EmailList.tsx +++ b/src/EmailList.tsx @@ -34,7 +34,7 @@ class EmailList extends React.Component { return ; } else { return ( - + {this.props.mailbox.emailIds.slice(0, 5).map((e) => ( +
{this.props.email != null ? this.props.email.subject : this.props.emailId} From d6b675f7b6badb17825881015193bf4644ccd254 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Wed, 28 Aug 2024 10:16:11 -0700 Subject: [PATCH 04/18] Allow selecting an email, and adding it to the hash. We don't do anything with it yet, though --- src/EmailList.tsx | 1 + src/EmailSummary.tsx | 27 +++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/EmailList.tsx b/src/EmailList.tsx index 7ff49b4..fc9151a 100644 --- a/src/EmailList.tsx +++ b/src/EmailList.tsx @@ -42,6 +42,7 @@ class EmailList extends React.Component { emailId={e} email={this.props.client!.email(e)} key={e} + mailbox={this.props.mailbox} /> ))} diff --git a/src/EmailSummary.tsx b/src/EmailSummary.tsx index afc7c97..5520ba1 100644 --- a/src/EmailSummary.tsx +++ b/src/EmailSummary.tsx @@ -1,12 +1,15 @@ import React from "react"; +import Placeholder from "react-bootstrap/Placeholder"; + import Client from "./client/Client"; -import { IAccount, IEmail } from "./client/types"; +import { IAccount, IEmail, IMailbox } from "./client/types"; type EmailSummaryProps = { account: IAccount | null; client: Client | null; email: IEmail | null; emailId: string; + mailbox: IMailbox | null; }; type EmailSummaryState = {}; @@ -28,11 +31,27 @@ class EmailSummary extends React.Component< } render() { + if (this.props.account == null || this.props.mailbox == null) { + return ( +
+ +
+ ); + } + const href = + "#" + + this.props.account.id + + "/" + + this.props.mailbox.id + + "/" + + this.props.emailId; return (
- {this.props.email != null - ? this.props.email.subject - : this.props.emailId} + + {this.props.email != null + ? this.props.email.subject + : this.props.emailId} +
); } From bf1ad0326d006f464e4150580009f5f9c47f2af4 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Wed, 28 Aug 2024 10:16:40 -0700 Subject: [PATCH 05/18] Show debug log messages on triggering changes --- src/client/Client.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/client/Client.tsx b/src/client/Client.tsx index fde5d63..e96c6e8 100644 --- a/src/client/Client.tsx +++ b/src/client/Client.tsx @@ -106,7 +106,7 @@ export default class Client { ); } this.state.session.emails[e.id] = e; - this._triggerChange(); + this._triggerChange("Email " + e.id); }); }) .catch((x) => { @@ -125,7 +125,7 @@ export default class Client { const mailbox = this.mailbox(accountId, mailboxId); if (mailbox == null) return; mailbox.emailIds = response.ids; - this._triggerChange(); + this._triggerChange("Email list " + mailboxId); }) .catch(() => { console.error("Failed to get email list from mailbox", mailboxId); @@ -165,11 +165,12 @@ export default class Client { }); }); account.mailboxes = mailboxes; - this._triggerChange(); + this._triggerChange("Mailboxes " + accountId); }); } - _triggerChange() { + _triggerChange(msg: string) { + console.log("Client change", msg); this.callbacks.forEach((c) => { c(); }); @@ -192,6 +193,6 @@ export default class Client { emails: {}, }; if (!this.state.session) return; - this._triggerChange(); + this._triggerChange("Session"); } } From fb53a7506f66eda0649c26eaa60d95a31fc82ee8 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Wed, 28 Aug 2024 10:35:48 -0700 Subject: [PATCH 06/18] Standardize on links for refresh. Turns out I was doubling-up unnecessarily and had an event handler _and_ a hash change detection. This just made things complex. Now I use the hash for both the mailbox and the email navigation. I don't yet do anything with the email part. --- src/App.tsx | 26 +++++++++++--------------- src/AppLayout.tsx | 10 +++------- src/Mailbox.tsx | 21 ++++++++++++++------- src/MailboxList.tsx | 20 ++++++-------------- 4 files changed, 34 insertions(+), 43 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index ee36fdc..297f4df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,13 +2,14 @@ import "./App.css"; import "./style.scss"; import Client, { IAuth } from "./client/Client"; -import { AccountIdMap, IAccount, IMailbox } from "./client/types"; +import { AccountIdMap, IAccount, IEmail, IMailbox } from "./client/types"; import AppLayout from "./AppLayout"; import AuthModal from "./AuthModal"; import React from "react"; interface ILocation { accountId: string; + emailId: string; mailboxId: string; } @@ -16,6 +17,7 @@ type AppState = { auth: IAuth; account: IAccount | null; accounts: AccountIdMap; + email: IEmail | null; location: ILocation; mailbox: IMailbox | null; }; @@ -37,7 +39,8 @@ class App extends React.Component { account: null, accounts: {}, auth: { email: "", password: "" }, - location: { accountId: "", mailboxId: "" }, + email: null, + location: { accountId: "", emailId: "", mailboxId: "" }, mailbox: null, }; @@ -46,14 +49,19 @@ class App extends React.Component { const parts = hash.split("/"); const accountId = parts[0]; const mailboxId = parts[1]; + const emailId = parts[2]; this.setState({ ...this.state, account: this.account(), location: { accountId: accountId, + emailId: emailId, mailboxId: mailboxId, }, + mailbox: this.client.mailbox(accountId, mailboxId), }); + if (!this.state.account) return; + this.client.ensureEmailList(accountId, mailboxId); } // When the user provides credentials @@ -70,16 +78,6 @@ class App extends React.Component { this.client.doLogin({ email, password }); } - onMailboxSelect(mailboxId: string) { - if (!this.state.account) return; - this.client.emailList(this.state.account.id, mailboxId, []); - this.setState({ - ...this.state, - mailbox: this.client.mailbox(this.state.account.id, mailboxId), - }); - window.location.hash = this.state.location.accountId + "/" + mailboxId; - } - // Load up auth credentials from the local store loadAuth() { const data = localStorage.getItem("auth"); @@ -131,10 +129,8 @@ class App extends React.Component { account={this.state.account} accounts={this.state.accounts} client={this.client} + email={this.state.email} mailbox={this.state.mailbox} - onMailboxSelect={(m) => { - this.onMailboxSelect(m); - }} /> void; }; const AppLayout: React.FC = (props) => { @@ -29,11 +29,7 @@ const AppLayout: React.FC = (props) => { - + = ({ accountId, id, name }) => { + const href = "#" + accountId + "/" + id; -function Mailbox() { return ( - - Inbox - Spam - + + {name} + ); -} +}; export default Mailbox; diff --git a/src/MailboxList.tsx b/src/MailboxList.tsx index f42a241..3bbe6a6 100644 --- a/src/MailboxList.tsx +++ b/src/MailboxList.tsx @@ -1,14 +1,13 @@ import React from "react"; -import Button from "react-bootstrap/Button"; import Stack from "react-bootstrap/Stack"; import Client from "./client/Client"; import { IAccount } from "./client/types"; +import Mailbox from "./Mailbox"; type MailboxListProps = { account: IAccount | null; client: Client | null; - onMailboxSelect: (mailboxId: string) => void; }; type MailboxListState = {}; @@ -20,10 +19,6 @@ class MailboxList extends React.Component { this.props.client.mailboxList(this.props.account.id, []); } - onMailboxClick(id: string) { - this.props.onMailboxSelect(id); - } - render() { return this.props.account == null || this.props.account.mailboxes == null ? ( @@ -31,15 +26,12 @@ class MailboxList extends React.Component { ) : ( {this.props.account.mailboxes.map((m) => ( - + name={m.name} + /> ))} ); From 4e1922c5fa9bfcdbee462fefdd0e606ef2dea6ad Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Wed, 28 Aug 2024 19:25:20 -0700 Subject: [PATCH 07/18] Split email cache into full content and stubs. This also introduces status for in-flight requests to avoid perpetual, unnecessary loops of change-the-re-get-data. --- src/App.tsx | 3 + src/AppLayout.tsx | 7 +- src/EmailArea.tsx | 44 +++++++++++++ src/EmailContent.tsx | 44 +++++++++++++ src/EmailList.tsx | 2 +- src/EmailSummary.tsx | 13 ++-- src/client/Client.tsx | 145 +++++++++++++++++++++++++++++++----------- src/client/types.tsx | 8 ++- 8 files changed, 219 insertions(+), 47 deletions(-) create mode 100644 src/EmailArea.tsx create mode 100644 src/EmailContent.tsx diff --git a/src/App.tsx b/src/App.tsx index 297f4df..78eec8c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,7 @@ class App extends React.Component { this.setState({ ...this.state, account: this.account(), + email: this.client.email(emailId), location: { accountId: accountId, emailId: emailId, @@ -107,6 +108,7 @@ class App extends React.Component { accounts: this.client.state.session ? this.client.state.session.accounts : {}, + email: this.client.email(this.state.location.emailId), mailbox: this.mailbox(), }); }); @@ -130,6 +132,7 @@ class App extends React.Component { accounts={this.state.accounts} client={this.client} email={this.state.email} + emailId={this.state.location.emailId} mailbox={this.state.mailbox} /> = (props) => { - diff --git a/src/EmailArea.tsx b/src/EmailArea.tsx new file mode 100644 index 0000000..3d3c049 --- /dev/null +++ b/src/EmailArea.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import Stack from "react-bootstrap/Stack"; + +import { IAccount, IEmail, IMailbox } from "./client/types"; +import Client from "./client/Client"; +import EmailContent from "./EmailContent"; +import EmailList from "./EmailList"; + +type EmailAreaProps = { + account: IAccount | null; + client: Client; + email: IEmail | null; + emailId: string; + mailbox: IMailbox | null; +}; + +const EmailArea: React.FC = (props) => { + if (props.emailId === "") { + return ( + + ); + } else { + return ( + + + + + ); + } +}; +export default EmailArea; diff --git a/src/EmailContent.tsx b/src/EmailContent.tsx new file mode 100644 index 0000000..6506fdf --- /dev/null +++ b/src/EmailContent.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import Placeholder from "react-bootstrap/Placeholder"; + +import Client from "./client/Client"; +import { IAccount, IEmail } from "./client/types"; + +type EmailContentProps = { + account: IAccount | null; + client: Client | null; + email: IEmail | null; + emailId: string; +}; + +type EmailContentState = {}; + +class EmailContent extends React.Component< + EmailContentProps, + EmailContentState +> { + componentDidMount() { + this.ensureData(); + } + componentDidUpdate() { + this.ensureData(); + } + + ensureData() { + if (this.props.account == null) return; + if (this.props.client == null) return; + this.props.client.ensureEmailContent( + this.props.account.id, + this.props.emailId, + ); + } + + render() { + if (this.props.email == null) { + return ; + } else { + return

{this.props.email.preview}

; + } + } +} +export default EmailContent; diff --git a/src/EmailList.tsx b/src/EmailList.tsx index fc9151a..69ebd3d 100644 --- a/src/EmailList.tsx +++ b/src/EmailList.tsx @@ -40,7 +40,7 @@ class EmailList extends React.Component { account={this.props.account} client={this.props.client} emailId={e} - email={this.props.client!.email(e)} + emailStub={this.props.client!.emailStub(e)} key={e} mailbox={this.props.mailbox} /> diff --git a/src/EmailSummary.tsx b/src/EmailSummary.tsx index 5520ba1..f777e4a 100644 --- a/src/EmailSummary.tsx +++ b/src/EmailSummary.tsx @@ -2,13 +2,13 @@ import React from "react"; import Placeholder from "react-bootstrap/Placeholder"; import Client from "./client/Client"; -import { IAccount, IEmail, IMailbox } from "./client/types"; +import { IAccount, IEmailStub, IMailbox } from "./client/types"; type EmailSummaryProps = { account: IAccount | null; client: Client | null; - email: IEmail | null; emailId: string; + emailStub: IEmailStub | null; mailbox: IMailbox | null; }; type EmailSummaryState = {}; @@ -27,7 +27,10 @@ class EmailSummary extends React.Component< ensureData() { if (this.props.account == null) return; if (this.props.client == null) return; - this.props.client.ensureEmailGet(this.props.account.id, this.props.emailId); + this.props.client.ensureEmailStub( + this.props.account.id, + this.props.emailId, + ); } render() { @@ -48,8 +51,8 @@ class EmailSummary extends React.Component< return ( diff --git a/src/client/Client.tsx b/src/client/Client.tsx index e96c6e8..368ba74 100644 --- a/src/client/Client.tsx +++ b/src/client/Client.tsx @@ -6,7 +6,7 @@ import * as base64 from "base-64"; import * as jmapclient from "jmap-client-ts"; import { FetchTransport } from "jmap-client-ts/lib/utils/fetch-transport"; -import { IAccount, IEmail, IMailbox, ISession } from "./types"; +import { IAccount, IEmail, IEmailStub, IMailbox, ISession } from "./types"; type Callback = () => void; @@ -16,45 +16,31 @@ export interface IAuth { } export interface ClientState { + inFlight: { + emailContents: Set; + emailStubs: Set; + mailboxes: Set; + }; session: ISession | null; } export default class Client { - // Get the currently active account - account(accountId: string): IAccount | null { - if (!(this.state.session && this.state.session.accounts)) return null; - return this.state.session.accounts[accountId]; - } // All objects which currently are listening for changes callbacks: Array = []; jclient: jmapclient.Client | null = null; state: ClientState = { + inFlight: { + emailContents: new Set(), + emailStubs: new Set(), + mailboxes: new Set(), + }, session: null, }; - onChange(f: Callback) { - this.callbacks.push(f); - } - - // Ensure we have the fully-fleshed email - ensureEmailGet(accountId: string, emailId: string) { - if (this.state.session == null) return; - const existing = this.state.session.emails[emailId]; - if (existing != null) return; - this.emailGet(accountId, emailId); - } - - // Ensure we have the list of emails for the provided account and mailbox - ensureEmailList(accountId: string, mailboxId: string) { - const mailbox = this.mailbox(accountId, mailboxId); - if (mailbox != null && mailbox.emailIds != null) return; - this.emailList(accountId, mailboxId, []); - } - - // Ensure that login happens. If this is called many times, only login once. - ensureLogin(auth: IAuth) { - if (this.jclient != null) return; - this.doLogin(auth); + // Get the currently active account + account(accountId: string): IAccount | null { + if (!(this.state.session && this.state.session.accounts)) return null; + return this.state.session.accounts[accountId]; } // Make the request to get system metadata @@ -81,36 +67,110 @@ export default class Client { return; } + // Ensure we have the full email content + ensureEmailContent(accountId: string, emailId: string) { + if (this.state.session == null) return; + const existing = this.state.session.emails[emailId]; + if (existing != null) return; + this.emailGetContent(accountId, emailId); + } + + // Ensure we have the email summary + ensureEmailStub(accountId: string, emailId: string) { + if (this.state.session == null) return; + const existing = this.state.session.emailStubs[emailId]; + if (existing != null) return; + this.emailGetStub(accountId, emailId); + } + + // Ensure we have the list of emails for the provided account and mailbox + ensureEmailList(accountId: string, mailboxId: string) { + const mailbox = this.mailbox(accountId, mailboxId); + if (mailbox != null && mailbox.emailIds != null) return; + this.emailList(accountId, mailboxId, []); + } + + // Ensure that login happens. If this is called many times, only login once. + ensureLogin(auth: IAuth) { + if (this.jclient != null) return; + this.doLogin(auth); + } + email(emailId: string): IEmail | null { if (this.state.session == null) return null; return this.state.session.emails[emailId]; } - emailGet(accountId: string, emailId: string) { + emailGetContent(accountId: string, emailId: string) { if (this.jclient == null) return; + if (this.state.session == null) return; + if (this.state.inFlight.emailContents.has(emailId)) return; + this.state.inFlight.emailContents.add(emailId); + const msg = "Email get content"; this.jclient .email_get({ accountId: accountId, ids: [emailId], - properties: ["mailboxIds", "subject"], }) .then((response) => { - console.log("Email response", response); + console.log(msg, "response", response); response.list.forEach((e) => { - if (this.state.session == null) return; - const existing = this.state.session.emails[e.id]; + const existing = this.state.session!.emails[e.id]; if (existing !== undefined && existing.subject !== e.subject) { console.error( "Expectations violation: email ID is not unique within the server!", ); } - this.state.session.emails[e.id] = e; - this._triggerChange("Email " + e.id); + this.state.session!.emails[e.id] = { + ...e, + id: e.id, + }; + this._triggerChange(msg + e.id); }); }) .catch((x) => { - console.error("Failed to get email", emailId, x); + console.error("Failed to get email content", emailId, x); + }) + .finally(() => { + this.state.inFlight.emailContents.delete(emailId); + }); + } + + emailGetStub(accountId: string, emailId: string) { + if (this.jclient == null) return; + if (this.state.session == null) return; + if (this.state.inFlight.emailStubs.has(emailId)) return; + this.state.inFlight.emailStubs.add(emailId); + const msg = "Email get summary"; + this.jclient + .email_get({ + accountId: accountId, + ids: [emailId], + properties: ["subject"], + }) + .then((response) => { + console.log(msg, "response", response); + response.list.forEach((e) => { + if (this.state.session == null) return; + const existing = this.state.session.emailStubs[e.id]; + if (existing !== undefined && existing.subject !== e.subject) { + console.error( + "Expectations violation: email ID is not unique within the server!", + ); + } + this.state.session.emailStubs[e.id] = { + id: e.id, + subject: e.subject, + }; + this._triggerChange(msg + e.id); + }); + }) + .catch((x) => { + console.error("Failed to get email stub", emailId, x); + }) + .finally(() => { + this.state.inFlight.emailStubs.delete(emailId); }); } @@ -132,6 +192,11 @@ export default class Client { }); } + emailStub(emailId: string): IEmailStub | null { + if (this.state.session == null) return null; + return this.state.session.emailStubs[emailId]; + } + mailbox(accountId: string, mailboxId: string): IMailbox | null { if (this.state.session == null) return null; const account = this.state.session.accounts[accountId]; @@ -161,7 +226,6 @@ export default class Client { mailboxes.push({ ...m, emailIds: null, - emails: null, }); }); account.mailboxes = mailboxes; @@ -169,6 +233,10 @@ export default class Client { }); } + onChange(f: Callback) { + this.callbacks.push(f); + } + _triggerChange(msg: string) { console.log("Client change", msg); this.callbacks.forEach((c) => { @@ -191,6 +259,7 @@ export default class Client { ]), ), emails: {}, + emailStubs: {}, }; if (!this.state.session) return; this._triggerChange("Session"); diff --git a/src/client/types.tsx b/src/client/types.tsx index 08b9cf9..3906350 100644 --- a/src/client/types.tsx +++ b/src/client/types.tsx @@ -1,8 +1,12 @@ import client from "jmap-client-ts/lib/types"; +export interface IEmailStub { + id: string; + subject: string; +} + export interface IMailbox extends client.IMailboxProperties { emailIds: Array | null; - emails: Array | null; } export interface IEmail extends client.IEmailProperties {} @@ -13,9 +17,11 @@ export interface IAccount extends client.IAccount { } export type AccountIdMap = { [accountId: string]: IAccount }; +export type EmailStubIdMap = { [emailId: string]: IEmailStub }; export type EmailIdMap = { [emailId: string]: IEmail }; export interface ISession extends client.ISession { accounts: AccountIdMap; emails: EmailIdMap; + emailStubs: EmailStubIdMap; } From d71f18cce1041a4fc121f8a27b19999cf1d683b0 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Thu, 29 Aug 2024 10:27:00 -0700 Subject: [PATCH 08/18] Show text content, and if unavailable, the HTML content. This required actually requesting all the body values, and mapping from the body values to to body parts for display. --- src/EmailContent.tsx | 41 ++++++++++++++++++++++++++++++++++++++--- src/client/Client.tsx | 1 + 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/EmailContent.tsx b/src/EmailContent.tsx index 6506fdf..9dc3f3b 100644 --- a/src/EmailContent.tsx +++ b/src/EmailContent.tsx @@ -1,5 +1,6 @@ -import React from "react"; import Placeholder from "react-bootstrap/Placeholder"; +import React from "react"; +import Stack from "react-bootstrap/Stack"; import Client from "./client/Client"; import { IAccount, IEmail } from "./client/types"; @@ -34,10 +35,44 @@ class EmailContent extends React.Component< } render() { - if (this.props.email == null) { + const email = this.props.email; + if (email == null || email.bodyValues == null) { return ; + } else if (email.textBody != null) { + return ( + +
  • {email.receivedAt}
  • + {email.textBody.map((t) => + t.partId === undefined ? ( + + ) : ( +
    + ), + )} + + ); + } else if (email.htmlBody != null) { + return ( + + {email.htmlBody.map((h) => + h.partId === undefined ? ( + + ) : ( +
    + ), + )} + + ); } else { - return

    {this.props.email.preview}

    ; + return

    Nothing to display :/

    ; } } } diff --git a/src/client/Client.tsx b/src/client/Client.tsx index 368ba74..cc22889 100644 --- a/src/client/Client.tsx +++ b/src/client/Client.tsx @@ -112,6 +112,7 @@ export default class Client { .email_get({ accountId: accountId, ids: [emailId], + fetchAllBodyValues: true, }) .then((response) => { console.log(msg, "response", response); From 5c293219f3a950f8a2eecec1ebea50ea58e2d055 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Thu, 29 Aug 2024 10:47:09 -0700 Subject: [PATCH 09/18] Add 'from' and 'received at' to the email summary. Really useful in deciding what to read. --- src/EmailSummary.tsx | 9 +++++++-- src/client/Client.tsx | 10 +++++++++- src/client/types.tsx | 2 ++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/EmailSummary.tsx b/src/EmailSummary.tsx index f777e4a..0e57c1e 100644 --- a/src/EmailSummary.tsx +++ b/src/EmailSummary.tsx @@ -48,11 +48,16 @@ class EmailSummary extends React.Component< this.props.mailbox.id + "/" + this.props.emailId; + const stub = this.props.emailStub; return ( diff --git a/src/client/Client.tsx b/src/client/Client.tsx index cc22889..2ed7158 100644 --- a/src/client/Client.tsx +++ b/src/client/Client.tsx @@ -148,7 +148,7 @@ export default class Client { .email_get({ accountId: accountId, ids: [emailId], - properties: ["subject"], + properties: ["from", "receivedAt", "subject"], }) .then((response) => { console.log(msg, "response", response); @@ -161,7 +161,9 @@ export default class Client { ); } this.state.session.emailStubs[e.id] = { + from: e.from, id: e.id, + receivedAt: e.receivedAt, subject: e.subject, }; this._triggerChange(msg + e.id); @@ -181,6 +183,12 @@ export default class Client { .email_query({ accountId: accountId, filter: { inMailbox: mailboxId }, + sort: [ + { + property: "receivedAt", + isAscending: false, + }, + ], }) .then((response) => { const mailbox = this.mailbox(accountId, mailboxId); diff --git a/src/client/types.tsx b/src/client/types.tsx index 3906350..da345c1 100644 --- a/src/client/types.tsx +++ b/src/client/types.tsx @@ -1,7 +1,9 @@ import client from "jmap-client-ts/lib/types"; export interface IEmailStub { + from: Array | null; id: string; + receivedAt: string; subject: string; } From 3ef58b2259e8bdc61b3e7355cd691487b276866a Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Thu, 29 Aug 2024 10:47:35 -0700 Subject: [PATCH 10/18] Show the error when the email list fails. Useful when I was tring to query for more stuff. --- src/client/Client.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/Client.tsx b/src/client/Client.tsx index 2ed7158..08c1af9 100644 --- a/src/client/Client.tsx +++ b/src/client/Client.tsx @@ -196,8 +196,8 @@ export default class Client { mailbox.emailIds = response.ids; this._triggerChange("Email list " + mailboxId); }) - .catch(() => { - console.error("Failed to get email list from mailbox", mailboxId); + .catch((e) => { + console.error("Failed to get email list from mailbox", mailboxId, e); }); } From a65514f70704b7887001bcba7c11b0cceb4cf900 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Thu, 29 Aug 2024 11:25:59 -0700 Subject: [PATCH 11/18] Add note to add support for sentAt. It's in the spec, and I get it from the server, but it's not in the client library I'm using. --- src/client/types.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/client/types.tsx b/src/client/types.tsx index da345c1..f2aa964 100644 --- a/src/client/types.tsx +++ b/src/client/types.tsx @@ -11,7 +11,9 @@ export interface IMailbox extends client.IMailboxProperties { emailIds: Array | null; } -export interface IEmail extends client.IEmailProperties {} +export interface IEmail extends client.IEmailProperties { + //sentAt: IutcDate|null, +} export interface IAccount extends client.IAccount { id: string; From 356173e4a3a1eb7a376e1562fd7b99f5c2c827ff Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Thu, 29 Aug 2024 11:26:43 -0700 Subject: [PATCH 12/18] Create button to choose between HTML and text-based email content. --- src/EmailContent.tsx | 87 +++++++++++++++++++++++----------------- src/EmailContentHTML.tsx | 36 +++++++++++++++++ src/EmailContentText.tsx | 39 ++++++++++++++++++ 3 files changed, 126 insertions(+), 36 deletions(-) create mode 100644 src/EmailContentHTML.tsx create mode 100644 src/EmailContentText.tsx diff --git a/src/EmailContent.tsx b/src/EmailContent.tsx index 9dc3f3b..445f552 100644 --- a/src/EmailContent.tsx +++ b/src/EmailContent.tsx @@ -1,8 +1,12 @@ +import Button from "react-bootstrap/Button"; +import Container from "react-bootstrap/Container"; import Placeholder from "react-bootstrap/Placeholder"; import React from "react"; -import Stack from "react-bootstrap/Stack"; +import Row from "react-bootstrap/Row"; import Client from "./client/Client"; +import EmailContentHTML from "./EmailContentHTML"; +import EmailContentText from "./EmailContentText"; import { IAccount, IEmail } from "./client/types"; type EmailContentProps = { @@ -12,12 +16,18 @@ type EmailContentProps = { emailId: string; }; -type EmailContentState = {}; +type EmailContentState = { + showHTML: boolean; +}; class EmailContent extends React.Component< EmailContentProps, EmailContentState > { + state = { + showHTML: false, + }; + componentDidMount() { this.ensureData(); } @@ -38,41 +48,46 @@ class EmailContent extends React.Component< const email = this.props.email; if (email == null || email.bodyValues == null) { return ; - } else if (email.textBody != null) { - return ( - -
  • {email.receivedAt}
  • - {email.textBody.map((t) => - t.partId === undefined ? ( - - ) : ( -
    - ), - )} - - ); - } else if (email.htmlBody != null) { - return ( - - {email.htmlBody.map((h) => - h.partId === undefined ? ( - - ) : ( -
    - ), - )} - - ); + } + let content; + if (this.state.showHTML) { + if (email.htmlBody != null) { + content = ; + } else { + return

    No HTML content

    ; + } } else { - return

    Nothing to display :/

    ; + if (email.textBody != null) { + content = ; + } else { + return

    No text content/

    ; + } + } + return ( + + + + + {content} + + ); + } + + switchDisplay() { + if (this.state.showHTML) { + this.setState({ + showHTML: false, + }); + } else { + this.setState({ + showHTML: true, + }); } } } diff --git a/src/EmailContentHTML.tsx b/src/EmailContentHTML.tsx new file mode 100644 index 0000000..533716f --- /dev/null +++ b/src/EmailContentHTML.tsx @@ -0,0 +1,36 @@ +import Placeholder from "react-bootstrap/Placeholder"; +import React from "react"; +import Stack from "react-bootstrap/Stack"; + +import { IAccount, IEmail, IMailbox } from "./client/types"; +import Client from "./client/Client"; +import EmailContent from "./EmailContent"; +import EmailList from "./EmailList"; + +type EmailContentHTMLProps = { + email: IEmail; +}; + +const EmailContentHTML: React.FC = (props) => { + const email = props.email; + if (email.htmlBody == null) { + return ; + } + return ( + + {email.htmlBody.map((h) => + h.partId === undefined ? ( + + ) : ( +
    + ), + )} + + ); +}; +export default EmailContentHTML; diff --git a/src/EmailContentText.tsx b/src/EmailContentText.tsx new file mode 100644 index 0000000..e8b60a3 --- /dev/null +++ b/src/EmailContentText.tsx @@ -0,0 +1,39 @@ +import Placeholder from "react-bootstrap/Placeholder"; +import React from "react"; +import Stack from "react-bootstrap/Stack"; + +import { IAccount, IEmail, IMailbox } from "./client/types"; +import Client from "./client/Client"; +import EmailContent from "./EmailContent"; +import EmailList from "./EmailList"; + +type EmailContentTextProps = { + email: IEmail; +}; + +const EmailContentText: React.FC = (props) => { + const email = props.email; + if (email.textBody == null) { + return ; + } + return ( + +
  • {email.receivedAt}
  • +
    +				{email.textBody.map((t) =>
    +					t.partId === undefined ? (
    +						
    +					) : (
    +						
    + ), + )} +
    +
    + ); +}; +export default EmailContentText; From eb09b39de3f135985c9fb4ad167bf1a1b6184bc2 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Thu, 29 Aug 2024 11:41:41 -0700 Subject: [PATCH 13/18] Add beginning of email header information The format is butt-ugly, but at least I'm showing info. --- src/EmailContent.tsx | 26 ++++++++++++++++++++++++++ src/EmailContentText.tsx | 1 - 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/EmailContent.tsx b/src/EmailContent.tsx index 445f552..953d043 100644 --- a/src/EmailContent.tsx +++ b/src/EmailContent.tsx @@ -1,5 +1,7 @@ import Button from "react-bootstrap/Button"; +import Col from "react-bootstrap/Col"; import Container from "react-bootstrap/Container"; +import Form from "react-bootstrap/Form"; import Placeholder from "react-bootstrap/Placeholder"; import React from "react"; import Row from "react-bootstrap/Row"; @@ -65,6 +67,30 @@ class EmailContent extends React.Component< } return ( + +
    + + + From + + +

    + {email.from == null + ? "unknown" + : email.from.map((f) => f.name)} +

    + + + Received + + {email.receivedAt} +
    +
    +