mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
175 lines
7.0 KiB
TypeScript
175 lines
7.0 KiB
TypeScript
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
|
import type { Email, Mailbox } from '@/lib/jmap/types';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { useAccountStore } from '@/stores/account-store';
|
|
import { useEmailStore, resolveEmailActionContext } from '@/stores/email-store';
|
|
import { useIdentityStore } from '@/stores/identity-store';
|
|
import { cachedRemoteIdentities } from '@/hooks/use-pro-multi-account-identities';
|
|
import { extractListId, headerValue, normalizeAddress, unfoldHeader } from './quick-rules';
|
|
|
|
/** The account a rule made from a message goes into. */
|
|
export interface QuickRuleTarget {
|
|
/** The login client that reaches the account. */
|
|
client: IJMAPClient;
|
|
/** AccountEntry id of that login, when known. */
|
|
clientAccountId: string | null;
|
|
/** Login + JMAP account: equal for messages of one account. */
|
|
key: string;
|
|
/** JMAP mail account the message belongs to. */
|
|
accountId: string;
|
|
sieveAccountId: string;
|
|
/** A shared or group account: no rules are offered for it. */
|
|
shared: boolean;
|
|
supportsSieve: boolean;
|
|
/** The account's own folders (store ids). */
|
|
mailboxes: Mailbox[];
|
|
}
|
|
|
|
// JMAP account ids are opaque per server, so two logins can share one; the
|
|
// client object tells them apart.
|
|
const clientKeys = new WeakMap<object, number>();
|
|
let nextClientKey = 1;
|
|
function clientKey(client: IJMAPClient): number {
|
|
let key = clientKeys.get(client);
|
|
if (key === undefined) {
|
|
key = nextClientKey++;
|
|
clientKeys.set(client, key);
|
|
}
|
|
return key;
|
|
}
|
|
|
|
/**
|
|
* Resolve the account of `email` the way the email actions do (source stamps
|
|
* in aggregate views, else the viewed account), so a rule for a message of
|
|
* account B is never written to account A's script.
|
|
*/
|
|
export function resolveQuickRuleTarget(email: Email): QuickRuleTarget | null {
|
|
const auth = useAuthStore.getState();
|
|
if (!auth.client) return null;
|
|
let context: ReturnType<typeof resolveEmailActionContext>;
|
|
try {
|
|
context = resolveEmailActionContext(email, auth.client);
|
|
} catch {
|
|
return null; // a message of a login that is gone
|
|
}
|
|
const client = context.client;
|
|
const ownAccountId = client.getAccountId();
|
|
const accountId = context.accountId ?? ownAccountId;
|
|
// A shared folder's store id is namespaced (`owner:id`), and so are the
|
|
// mailboxIds of the messages in it. Its bare `originalId` can equal one of
|
|
// the user's own folder ids (a group account has an Inbox too), so it must
|
|
// not be compared.
|
|
const inSharedFolder = Object.keys(email.mailboxIds ?? {}).some(id =>
|
|
context.mailboxes.some(m => m.isShared && m.id === id));
|
|
const clientAccountId = email.sourceClientAccountId
|
|
?? useEmailStore.getState().viewingAccountId
|
|
?? auth.activeAccountId
|
|
?? null;
|
|
return {
|
|
client,
|
|
clientAccountId,
|
|
key: `${clientKey(client)}|${accountId}`,
|
|
accountId,
|
|
sieveAccountId: client.getSieveAccountId(),
|
|
shared: accountId !== ownAccountId || inSharedFolder,
|
|
supportsSieve: typeof client.supportsSieve === 'function' && client.supportsSieve(),
|
|
mailboxes: context.mailboxes.filter(m => !m.isShared),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The account a folder of the sidebar belongs to, for running rules on it.
|
|
* Resolved like the other folder actions (the viewed account); null when the
|
|
* folder is not one of that account's own.
|
|
*/
|
|
export function resolveMailboxRuleTarget(mailbox: Mailbox): QuickRuleTarget | null {
|
|
const auth = useAuthStore.getState();
|
|
if (!auth.client) return null;
|
|
const context = resolveEmailActionContext({}, auth.client);
|
|
const client = context.client;
|
|
const accountId = client.getAccountId();
|
|
const mailboxes = context.mailboxes.filter(m => !m.isShared);
|
|
if (!mailboxes.some(m => m.id === mailbox.id)) return null;
|
|
return {
|
|
client,
|
|
clientAccountId: useEmailStore.getState().viewingAccountId ?? auth.activeAccountId ?? null,
|
|
key: `${clientKey(client)}|${accountId}`,
|
|
accountId,
|
|
sieveAccountId: client.getSieveAccountId(),
|
|
shared: false,
|
|
supportsSieve: typeof client.supportsSieve === 'function' && client.supportsSieve(),
|
|
mailboxes,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The folder `email` is in, within the target account: the selected folder
|
|
* when the message is in it, else the message's own folder (unified, tag and
|
|
* search views have no real folder selected).
|
|
*/
|
|
export function sourceMailboxOf(email: Email, target: QuickRuleTarget): Mailbox | undefined {
|
|
const ids = Object.entries(email.mailboxIds ?? {}).filter(([, on]) => on).map(([id]) => id);
|
|
const selected = useEmailStore.getState().selectedMailbox;
|
|
const find = (id: string) => target.mailboxes.find(m => m.id === id);
|
|
if (selected && ids.includes(selected)) {
|
|
const mailbox = find(selected);
|
|
if (mailbox) return mailbox;
|
|
}
|
|
for (const id of ids) {
|
|
const mailbox = find(id);
|
|
if (mailbox) return mailbox;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Every address the user sends as, across the connected accounts: rules are
|
|
* never offered against one of them.
|
|
*/
|
|
export function getOwnAddresses(): Set<string> {
|
|
const addresses = new Set<string>();
|
|
const add = (value: string | null | undefined) => {
|
|
const address = normalizeAddress(value);
|
|
if (address.includes('@')) addresses.add(address);
|
|
};
|
|
for (const identity of useAuthStore.getState().identities ?? []) add(identity.email);
|
|
for (const identity of useIdentityStore.getState().identities ?? []) add(identity.email);
|
|
for (const identity of cachedRemoteIdentities()) add(identity.email);
|
|
for (const account of useAccountStore.getState().accounts ?? []) {
|
|
add(account.email);
|
|
add(account.username);
|
|
}
|
|
return addresses;
|
|
}
|
|
|
|
const listIdCache = new Map<string, string | null>();
|
|
|
|
/** The List-Id of `email` if it is known without a request. */
|
|
export function knownListId(target: QuickRuleTarget, email: Email): string | null | undefined {
|
|
const header = headerValue(email.headers, 'List-Id');
|
|
if (header !== undefined) return extractListId(header);
|
|
// A fetched message carries every header it has; no List-Id means none.
|
|
if (email.headers && Object.keys(email.headers).length > 0) return null;
|
|
return listIdCache.get(`${target.key}|${email.id}`);
|
|
}
|
|
|
|
/**
|
|
* Fetch the List-Id of the messages that do not carry their headers (list
|
|
* rows). Only the one header is asked for, and only when the Rules menu
|
|
* opens, so the list itself is not slowed down.
|
|
*/
|
|
export async function loadListIds(target: QuickRuleTarget, emails: Email[]): Promise<void> {
|
|
const missing = emails.filter(e => knownListId(target, e) === undefined).map(e => e.id);
|
|
if (missing.length === 0) return;
|
|
// Stalwart parses List-Id as a structured header and answers the text form
|
|
// with null, so the raw form is asked for too; the id itself is plain ASCII.
|
|
const records = await target.client.getEmailFields(
|
|
missing, ['header:List-Id:asText', 'header:List-Id'], target.accountId,
|
|
);
|
|
const found = new Map(records.map(r => [
|
|
String(r.id),
|
|
extractListId(r['header:List-Id:asText'] ?? unfoldHeader(r['header:List-Id'])),
|
|
]));
|
|
for (const id of missing) listIdCache.set(`${target.key}|${id}`, found.get(id) ?? null);
|
|
}
|