mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
150 lines
6.4 KiB
TypeScript
150 lines
6.4 KiB
TypeScript
// A plugin opening a message in the reader (api.ui.openEmail): what a click on
|
|
// a list row, or on a search result entry naming a message (#1338), does. The
|
|
// message is reached through the login that reads its account, the active one
|
|
// first, so a shared or group account's message opens too. The mail page opens
|
|
// it in place through the handler it sets; the Pro shell, whose mail tab stays
|
|
// mounted (hidden) beside the others, opens it in an email tab as a message
|
|
// link does there; on any other page the message is looked up and the tab
|
|
// loads its permalink, which names the login and the account in its query (a
|
|
// move within the app would drop that query before the mail page reads it). A
|
|
// message no login reads, or one its account does not have, is refused with an
|
|
// error the plugin sees.
|
|
|
|
import type { Email } from '../jmap/types';
|
|
import type { InstalledPlugin, SearchResultEmail } from '../plugin-types';
|
|
import { appPath, buildMailPath } from '../deep-links';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { useProTabStore } from '@/stores/pro-tab-store';
|
|
import { readId, readObject, withinRate } from './host-api-input';
|
|
|
|
/** A message as the reader opens one that is not a row of the list: the login reaching it and its account. */
|
|
export type OpenableEmail = Required<Pick<Email, 'id' | 'sourceClientAccountId' | 'sourceAccountId'>>;
|
|
|
|
/**
|
|
* The login through which the user reaches a message, the active one first;
|
|
* null when none does (an account the user cannot read). Without an account
|
|
* the message is the active login's own.
|
|
*/
|
|
export function reachableEmail(email: SearchResultEmail): OpenableEmail | null {
|
|
const auth = useAuthStore.getState();
|
|
const clients = [...auth.getAllConnectedClients()]
|
|
.sort(([a], [b]) => Number(b === auth.activeAccountId) - Number(a === auth.activeAccountId));
|
|
for (const [login, client] of clients) {
|
|
if (!email.accountId) {
|
|
return login === auth.activeAccountId
|
|
? { id: email.id, sourceClientAccountId: login, sourceAccountId: client.getAccountId() }
|
|
: null;
|
|
}
|
|
const reaches = client.getAccountId() === email.accountId
|
|
|| client.getSharedAccounts().some((a) => a.id === email.accountId && a.capabilities.mail);
|
|
if (reaches) return { id: email.id, sourceClientAccountId: login, sourceAccountId: email.accountId };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* What opening a message came to: 'opened', 'missing' when its account does
|
|
* not have it, or the error that kept it from being tried or read.
|
|
*/
|
|
export type OpenEmailOutcome = 'opened' | 'missing' | Error;
|
|
|
|
/** Opens the message in the reader. */
|
|
export type OpenEmailHandler = (email: OpenableEmail) => Promise<OpenEmailOutcome>;
|
|
|
|
// The mail page or Pro shell on screen, the latest mounted last; it takes the
|
|
// request.
|
|
const handlers: OpenEmailHandler[] = [];
|
|
|
|
export function onOpenEmail(handler: OpenEmailHandler): () => void {
|
|
handlers.push(handler);
|
|
return () => {
|
|
const at = handlers.lastIndexOf(handler);
|
|
if (at >= 0) handlers.splice(at, 1);
|
|
};
|
|
}
|
|
|
|
/** The permalink of a message, naming its login and account only when they are not the active login's own. */
|
|
function messagePath(email: OpenableEmail): string {
|
|
const auth = useAuthStore.getState();
|
|
const own = auth.getAllConnectedClients().get(email.sourceClientAccountId)?.getAccountId();
|
|
return buildMailPath({
|
|
mailboxId: null,
|
|
threadId: null,
|
|
emailId: email.id,
|
|
loginId: email.sourceClientAccountId === auth.activeAccountId ? null : email.sourceClientAccountId,
|
|
ownerAccountId: email.sourceAccountId === own ? null : email.sourceAccountId,
|
|
});
|
|
}
|
|
|
|
/** Whether the message's account has it. */
|
|
async function exists(email: OpenableEmail): Promise<boolean> {
|
|
const client = useAuthStore.getState().getAllConnectedClients().get(email.sourceClientAccountId);
|
|
if (!client) return false;
|
|
const found = await client.getEmailFields([email.id], [], email.sourceAccountId);
|
|
return found.some((e) => e.id === email.id);
|
|
}
|
|
|
|
/**
|
|
* The Pro shell's handler: the message in an email tab, as a message link
|
|
* opens there (the tab fetches it and takes its subject as its title), once
|
|
* its account is known to have it.
|
|
*/
|
|
export function proEmailTabOpener(loadingTitle: string): OpenEmailHandler {
|
|
return async (email) => {
|
|
if (!(await exists(email))) return 'missing';
|
|
useProTabStore.getState().openEmailTab({
|
|
accountId: email.sourceAccountId,
|
|
clientAccountId: email.sourceClientAccountId,
|
|
emailId: email.id,
|
|
mailboxId: null,
|
|
title: loadingTitle,
|
|
});
|
|
return 'opened';
|
|
};
|
|
}
|
|
|
|
/** Times a minute a plugin may move the tab to a message's link. */
|
|
const LINK_LOADS_PER_MINUTE = 5;
|
|
|
|
/**
|
|
* Times a minute a plugin may open a message in place. Opening one takes over
|
|
* the reader and puts an open composer aside, so a plugin calling this in a
|
|
* loop would keep the user from reading or writing; a user clicking through a
|
|
* plugin's list stays well below it.
|
|
*/
|
|
const IN_PLACE_OPENS_PER_MINUTE = 30;
|
|
|
|
/** Checks the request and opens the message, or throws an error the plugin sees. */
|
|
export async function requestOpenEmail(plugin: InstalledPlugin, input: unknown): Promise<void> {
|
|
const method = 'ui.openEmail';
|
|
const data = readObject(input, method, 'email', ['id', 'accountId']);
|
|
const id = readId(data.id, method);
|
|
const accountId = data.accountId === undefined ? undefined : readId(data.accountId, method, 'accountId');
|
|
const email = reachableEmail({ id, ...(accountId ? { accountId } : {}) });
|
|
if (!email) {
|
|
throw new Error(accountId
|
|
? `${method}: the user cannot read the account "${accountId}"`
|
|
: `${method}: no account is signed in`);
|
|
}
|
|
const handler = handlers.at(-1);
|
|
if (handler) {
|
|
if (!withinRate('open-email-in-place', plugin.id, IN_PLACE_OPENS_PER_MINUTE)) {
|
|
throw new Error(`${method}: at most ${IN_PLACE_OPENS_PER_MINUTE} times a minute`);
|
|
}
|
|
const outcome = await handler(email);
|
|
if (outcome === 'missing') throw new Error(`${method}: no message "${id}" in the account`);
|
|
if (outcome instanceof Error) throw new Error(`${method}: ${outcome.message}`);
|
|
return;
|
|
}
|
|
if (!(await exists(email))) throw new Error(`${method}: no message "${id}" in the account`);
|
|
if (!withinRate('open-email', plugin.id, LINK_LOADS_PER_MINUTE)) {
|
|
throw new Error(`${method}: at most ${LINK_LOADS_PER_MINUTE} times a minute from outside the mail page`);
|
|
}
|
|
window.location.assign(appPath(messagePath(email)));
|
|
}
|
|
|
|
/** Test helper. */
|
|
export function resetOpenEmail(): void {
|
|
handlers.length = 0;
|
|
}
|