- Contact groups/lists, vCard import/export (RFC 6350), bulk operations - Advanced search with JMAP filter panel, search chips, cross-mailbox queries - Vacation responder with JMAP VacationResponse, settings tab, sidebar indicator - TOTP two-factor authentication support - Docker multi-stage build with standalone output and docker-compose - CSP Report-Only headers and security headers via proxy middleware - Virtual scrolling for large email lists - Structured server-side logger (text/JSON, configurable level) - 450+ tests (contacts, vCard, threads, headers, identity, components) - Playwright E2E framework setup - Updated README and ROADMAP with all new features
130 lines
2.9 KiB
TypeScript
130 lines
2.9 KiB
TypeScript
export interface SearchFilters {
|
|
from: string;
|
|
to: string;
|
|
subject: string;
|
|
body: string;
|
|
hasAttachment: boolean | null;
|
|
dateAfter: string;
|
|
dateBefore: string;
|
|
isUnread: boolean | null;
|
|
isStarred: boolean | null;
|
|
}
|
|
|
|
export const DEFAULT_SEARCH_FILTERS: SearchFilters = {
|
|
from: "",
|
|
to: "",
|
|
subject: "",
|
|
body: "",
|
|
hasAttachment: null,
|
|
dateAfter: "",
|
|
dateBefore: "",
|
|
isUnread: null,
|
|
isStarred: null,
|
|
};
|
|
|
|
export function buildJMAPFilter(
|
|
textQuery: string,
|
|
filters: SearchFilters,
|
|
mailboxId?: string
|
|
): Record<string, unknown> {
|
|
const conditions: Record<string, unknown>[] = [];
|
|
|
|
if (textQuery) {
|
|
conditions.push({ text: textQuery });
|
|
}
|
|
|
|
if (filters.from) {
|
|
conditions.push({ from: filters.from });
|
|
}
|
|
|
|
if (filters.to) {
|
|
conditions.push({ to: filters.to });
|
|
}
|
|
|
|
if (filters.subject) {
|
|
conditions.push({ subject: filters.subject });
|
|
}
|
|
|
|
if (filters.body) {
|
|
conditions.push({ body: filters.body });
|
|
}
|
|
|
|
if (filters.hasAttachment === true) {
|
|
conditions.push({ hasAttachment: true });
|
|
} else if (filters.hasAttachment === false) {
|
|
conditions.push({ hasAttachment: false });
|
|
}
|
|
|
|
if (filters.dateAfter) {
|
|
const date = new Date(filters.dateAfter);
|
|
if (!isNaN(date.getTime())) {
|
|
conditions.push({ after: date.toISOString() });
|
|
}
|
|
}
|
|
|
|
if (filters.dateBefore) {
|
|
const endOfDay = new Date(filters.dateBefore);
|
|
if (!isNaN(endOfDay.getTime())) {
|
|
endOfDay.setHours(23, 59, 59, 999);
|
|
conditions.push({ before: endOfDay.toISOString() });
|
|
}
|
|
}
|
|
|
|
if (filters.isUnread === true) {
|
|
conditions.push({ notKeyword: "$seen" });
|
|
} else if (filters.isUnread === false) {
|
|
conditions.push({ hasKeyword: "$seen" });
|
|
}
|
|
|
|
if (filters.isStarred === true) {
|
|
conditions.push({ hasKeyword: "$flagged" });
|
|
} else if (filters.isStarred === false) {
|
|
conditions.push({ notKeyword: "$flagged" });
|
|
}
|
|
|
|
if (mailboxId) {
|
|
conditions.push({ inMailbox: mailboxId });
|
|
}
|
|
|
|
if (conditions.length === 0) {
|
|
return mailboxId ? { inMailbox: mailboxId } : {};
|
|
}
|
|
|
|
if (conditions.length === 1) {
|
|
return conditions[0];
|
|
}
|
|
|
|
return {
|
|
operator: "AND",
|
|
conditions,
|
|
};
|
|
}
|
|
|
|
export function isFilterEmpty(filters: SearchFilters): boolean {
|
|
return (
|
|
!filters.from &&
|
|
!filters.to &&
|
|
!filters.subject &&
|
|
!filters.body &&
|
|
filters.hasAttachment === null &&
|
|
!filters.dateAfter &&
|
|
!filters.dateBefore &&
|
|
filters.isUnread === null &&
|
|
filters.isStarred === null
|
|
);
|
|
}
|
|
|
|
export function activeFilterCount(filters: SearchFilters): number {
|
|
let count = 0;
|
|
if (filters.from) count++;
|
|
if (filters.to) count++;
|
|
if (filters.subject) count++;
|
|
if (filters.body) count++;
|
|
if (filters.hasAttachment !== null) count++;
|
|
if (filters.dateAfter) count++;
|
|
if (filters.dateBefore) count++;
|
|
if (filters.isUnread !== null) count++;
|
|
if (filters.isStarred !== null) count++;
|
|
return count;
|
|
}
|