mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
263 lines
9.8 KiB
TypeScript
263 lines
9.8 KiB
TypeScript
import { create } from 'zustand';
|
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
|
import type { VacationAudience, VacationForward } from '@/lib/jmap/sieve-types';
|
|
import { readVacationFilters, syncVacationWithFilters, type VacationFilters } from '@/stores/filter-store';
|
|
import { withVacationPeriod } from '@/lib/sieve/vacation-forward';
|
|
import { currentStoreEpoch } from '@/lib/store-epoch';
|
|
import { debug } from '@/lib/debug';
|
|
import { vacationHooks } from '@/lib/plugin-hooks';
|
|
import { vacationToView } from '@/lib/plugin-projection';
|
|
|
|
/** What the vacation card sets for forwarding; the period comes from the vacation. */
|
|
export type VacationForwardSettings = Pick<VacationForward, 'enabled' | 'to' | 'keepCopy'>;
|
|
|
|
/**
|
|
* The vacation response was saved, but what the filters script carries for
|
|
* it (forwarding, who gets the auto-reply) was not: forwarding may not run
|
|
* or still run, and the auto-reply may go to the wrong senders.
|
|
*/
|
|
export class VacationFiltersError extends Error {
|
|
constructor(cause: unknown) {
|
|
super(cause instanceof Error ? cause.message : 'filters_save_error');
|
|
this.name = 'VacationFiltersError';
|
|
}
|
|
}
|
|
|
|
interface VacationStore {
|
|
isEnabled: boolean;
|
|
fromDate: string | null;
|
|
toDate: string | null;
|
|
subject: string;
|
|
textBody: string;
|
|
htmlBody: string | null;
|
|
/** Forwarding as stored in the filters script; null when none is set up. */
|
|
forward: VacationForward | null;
|
|
/** The account's filters script can carry forwarding (see readVacationFilters). */
|
|
forwardAvailable: boolean;
|
|
/** Who gets the auto-reply as stored; null when everyone does. */
|
|
audience: VacationAudience | null;
|
|
/** The auto-reply can be narrowed to some senders (see readVacationFilters). */
|
|
audienceAvailable: boolean;
|
|
/** Stored forwarding or recipients do not run until the card is saved (see readVacationFilters). */
|
|
notRunning: boolean;
|
|
/** The most forwards the filter rules let one message collect (see readVacationFilters). */
|
|
otherForwards: number;
|
|
isLoading: boolean;
|
|
isSaving: boolean;
|
|
error: string | null;
|
|
isSupported: boolean;
|
|
|
|
fetchVacationResponse: (client: IJMAPClient, accountId?: string) => Promise<void>;
|
|
/**
|
|
* Saves the out-of-office reply. Resolves false when a plugin stopped the
|
|
* change (onBeforeVacationUpdate), true once it was saved. Throws
|
|
* VacationFiltersError when only the filters script part could not be saved.
|
|
*/
|
|
updateVacationResponse: (client: IJMAPClient, updates: {
|
|
isEnabled?: boolean;
|
|
fromDate?: string | null;
|
|
toDate?: string | null;
|
|
subject?: string;
|
|
textBody?: string;
|
|
htmlBody?: string | null;
|
|
/** null removes the forwarding, leaving it out keeps it as it is. */
|
|
forward?: VacationForwardSettings | null;
|
|
/** null lets everyone have the auto-reply, leaving it out keeps it as it is. */
|
|
audience?: VacationAudience | null;
|
|
}, accountId?: string) => Promise<boolean>;
|
|
setSupported: (supported: boolean) => void;
|
|
clearState: () => void;
|
|
}
|
|
|
|
/** The card's part of what the filters script holds. */
|
|
function fromFilters(filters: VacationFilters) {
|
|
return {
|
|
forward: filters.forward,
|
|
forwardAvailable: filters.forwardAvailable,
|
|
audience: filters.audience,
|
|
audienceAvailable: filters.audienceAvailable,
|
|
notRunning: filters.notRunning,
|
|
otherForwards: filters.otherForwards,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Whether the account has a filters script to read and sync. A shared
|
|
* account goes by its own capabilities, which can lack Sieve where the
|
|
* session has it (#1263); the own account by the session.
|
|
*/
|
|
function hasSieve(client: IJMAPClient, accountId?: string): boolean {
|
|
return accountId ? client.accountSupports(accountId, 'urn:ietf:params:jmap:sieve') : client.supportsSieve();
|
|
}
|
|
|
|
// Each load, and each account switch (the store epoch), outdates the answers
|
|
// of the loads before it: one for another account must not land in the card
|
|
// on screen, where saving would write it to this account.
|
|
let loadGeneration = 0;
|
|
|
|
export const useVacationStore = create<VacationStore>()((set, get) => ({
|
|
isEnabled: false,
|
|
fromDate: null,
|
|
toDate: null,
|
|
subject: '',
|
|
textBody: '',
|
|
htmlBody: null,
|
|
forward: null,
|
|
forwardAvailable: false,
|
|
audience: null,
|
|
audienceAvailable: false,
|
|
notRunning: false,
|
|
otherForwards: 0,
|
|
isLoading: false,
|
|
isSaving: false,
|
|
error: null,
|
|
isSupported: false,
|
|
|
|
fetchVacationResponse: async (client, accountId) => {
|
|
const generation = ++loadGeneration;
|
|
const epoch = currentStoreEpoch();
|
|
const stale = () => generation !== loadGeneration || currentStoreEpoch() !== epoch;
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const vacation = await client.getVacationResponse(accountId);
|
|
let isEnabled = vacation.isEnabled;
|
|
let filters: VacationFilters | null = null;
|
|
if (hasSieve(client, accountId)) {
|
|
filters = await readVacationFilters(client, accountId).catch(() => null);
|
|
// Running from the filters script leaves the vacation script itself
|
|
// inactive, so VacationResponse reports it as off.
|
|
if (filters) isEnabled = isEnabled || filters.includesVacation;
|
|
}
|
|
if (stale()) return;
|
|
if (vacationHooks.onVacationLoaded.size > 0) {
|
|
void vacationHooks.onVacationLoaded.emit(vacationToView({ ...vacation, isEnabled }));
|
|
}
|
|
set({
|
|
isEnabled,
|
|
fromDate: vacation.fromDate,
|
|
toDate: vacation.toDate,
|
|
subject: vacation.subject || '',
|
|
textBody: vacation.textBody || '',
|
|
htmlBody: vacation.htmlBody,
|
|
forward: null,
|
|
forwardAvailable: false,
|
|
audience: null,
|
|
audienceAvailable: false,
|
|
notRunning: false,
|
|
otherForwards: 0,
|
|
...(filters ? fromFilters(filters) : {}),
|
|
isLoading: false,
|
|
});
|
|
} catch (error) {
|
|
if (stale()) return;
|
|
set({
|
|
isLoading: false,
|
|
error: error instanceof Error ? error.message : 'fetch_error',
|
|
});
|
|
}
|
|
},
|
|
|
|
updateVacationResponse: async (client, updates, accountId) => {
|
|
// The save itself goes to the account it was made for; only what it
|
|
// leaves in the store is dropped once another load or account took over.
|
|
const generation = loadGeneration;
|
|
const epoch = currentStoreEpoch();
|
|
const superseded = () => generation !== loadGeneration || currentStoreEpoch() !== epoch;
|
|
const { forward: forwardSettings, audience, ...vacationUpdates } = updates;
|
|
const before = get();
|
|
const next = vacationToView({
|
|
id: 'singleton',
|
|
isEnabled: before.isEnabled,
|
|
fromDate: before.fromDate,
|
|
toDate: before.toDate,
|
|
subject: before.subject,
|
|
textBody: before.textBody,
|
|
htmlBody: before.htmlBody,
|
|
...vacationUpdates,
|
|
});
|
|
if (!(await vacationHooks.onBeforeVacationUpdate.intercept(next))) return false;
|
|
set({ isSaving: true, error: null });
|
|
try {
|
|
await client.setVacationResponse(vacationUpdates, accountId);
|
|
} catch (error) {
|
|
set(superseded()
|
|
? { isSaving: false }
|
|
: { isSaving: false, error: error instanceof Error ? error.message : 'save_error' });
|
|
throw error;
|
|
}
|
|
|
|
// Forwarding runs in the vacation's period, so it takes the dates along.
|
|
const state = get();
|
|
const fromDate = vacationUpdates.fromDate !== undefined ? vacationUpdates.fromDate : state.fromDate;
|
|
const toDate = vacationUpdates.toDate !== undefined ? vacationUpdates.toDate : state.toDate;
|
|
const period = { from: fromDate, until: toDate };
|
|
|
|
let filtersError: VacationFiltersError | null = null;
|
|
let filtersState: Partial<VacationStore> = {};
|
|
const needsSync = vacationUpdates.isEnabled !== undefined || forwardSettings !== undefined || audience !== undefined;
|
|
if (needsSync && hasSieve(client, accountId)) {
|
|
let synced = false;
|
|
try {
|
|
await syncVacationWithFilters(
|
|
client, vacationUpdates.isEnabled ?? state.isEnabled, accountId, forwardSettings, audience, period,
|
|
);
|
|
synced = true;
|
|
} catch (error) {
|
|
debug.error('Failed to keep filters active next to the vacation response:', error);
|
|
// Forwarding that is or was on may now not run, or still run; an
|
|
// auto-reply meant for some senders may go to everyone, or the other
|
|
// way round.
|
|
if (forwardSettings?.enabled || state.forward?.enabled || audience || state.audience) {
|
|
filtersError = new VacationFiltersError(error);
|
|
}
|
|
}
|
|
// What the filters script holds now, and whether it runs. After a
|
|
// failed save the card keeps what the user set, to save it again.
|
|
const filters = await readVacationFilters(client, accountId).catch(() => null);
|
|
if (filters) {
|
|
filtersState = synced ? fromFilters(filters) : { notRunning: filters.notRunning };
|
|
} else if (synced) {
|
|
filtersState = {
|
|
...(forwardSettings !== undefined
|
|
? { forward: forwardSettings && withVacationPeriod(forwardSettings, period) }
|
|
: {}),
|
|
...(audience !== undefined ? { audience } : {}),
|
|
notRunning: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
set((current) => (superseded()
|
|
? { ...current, isSaving: false }
|
|
: { ...current, ...vacationUpdates, ...filtersState, isSaving: false }));
|
|
// The reply itself is saved even when its filters part failed.
|
|
void vacationHooks.onVacationUpdate.emit(next);
|
|
if (filtersError) throw filtersError;
|
|
return true;
|
|
},
|
|
|
|
setSupported: (supported) => set({ isSupported: supported }),
|
|
|
|
clearState: () => {
|
|
loadGeneration++;
|
|
set({
|
|
isEnabled: false,
|
|
fromDate: null,
|
|
toDate: null,
|
|
subject: '',
|
|
textBody: '',
|
|
htmlBody: null,
|
|
forward: null,
|
|
forwardAvailable: false,
|
|
audience: null,
|
|
audienceAvailable: false,
|
|
notRunning: false,
|
|
otherForwards: 0,
|
|
isLoading: false,
|
|
isSaving: false,
|
|
error: null,
|
|
isSupported: false,
|
|
});
|
|
},
|
|
}));
|