feat: add contacts phase 2, advanced search, vacation responder, Docker & TOTP 2FA
- 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
This commit is contained in:
+110
-1
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse } from "./types";
|
||||
|
||||
// JMAP protocol types - these are intentionally flexible due to server variations
|
||||
interface JMAPSession {
|
||||
@@ -866,6 +866,61 @@ export class JMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async advancedSearchEmails(
|
||||
filter: Record<string, unknown>,
|
||||
accountId?: string,
|
||||
limit: number = 50,
|
||||
position: number = 0
|
||||
): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
||||
try {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
|
||||
const response = await this.request([
|
||||
["Email/query", {
|
||||
accountId: targetAccountId,
|
||||
filter,
|
||||
sort: [{ property: "receivedAt", isAscending: false }],
|
||||
limit,
|
||||
position,
|
||||
}, "0"],
|
||||
["Email/get", {
|
||||
accountId: targetAccountId,
|
||||
"#ids": {
|
||||
resultOf: "0",
|
||||
name: "Email/query",
|
||||
path: "/ids",
|
||||
},
|
||||
properties: [
|
||||
"id",
|
||||
"threadId",
|
||||
"mailboxIds",
|
||||
"keywords",
|
||||
"size",
|
||||
"receivedAt",
|
||||
"from",
|
||||
"to",
|
||||
"cc",
|
||||
"subject",
|
||||
"preview",
|
||||
"hasAttachment",
|
||||
],
|
||||
}, "1"],
|
||||
]);
|
||||
|
||||
const queryResponse = response.methodResponses?.[0]?.[1];
|
||||
const emails = response.methodResponses?.[1]?.[1]?.list || [];
|
||||
const total = queryResponse?.total || 0;
|
||||
const hasMore = total > 0
|
||||
? (position + emails.length) < total
|
||||
: emails.length === limit;
|
||||
|
||||
return { emails, hasMore, total };
|
||||
} catch (error) {
|
||||
console.error('Advanced search failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Thread methods for conversation view
|
||||
async getThread(threadId: string, accountId?: string): Promise<Thread | null> {
|
||||
try {
|
||||
@@ -1090,6 +1145,60 @@ export class JMAPClient {
|
||||
throw new Error("Failed to delete identity: Server response was unexpected. Check server logs.");
|
||||
}
|
||||
|
||||
private vacationUsing(): string[] {
|
||||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:vacationresponse"];
|
||||
}
|
||||
|
||||
async getVacationResponse(): Promise<VacationResponse> {
|
||||
const response = await this.request([
|
||||
["VacationResponse/get", {
|
||||
accountId: this.accountId,
|
||||
ids: ["singleton"],
|
||||
}, "0"]
|
||||
], this.vacationUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "VacationResponse/get") {
|
||||
const list = response.methodResponses[0][1].list || [];
|
||||
if (list.length > 0) {
|
||||
return list[0] as VacationResponse;
|
||||
}
|
||||
return {
|
||||
id: "singleton",
|
||||
isEnabled: false,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: "",
|
||||
textBody: "",
|
||||
htmlBody: null,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("Failed to fetch vacation response: unexpected server response");
|
||||
}
|
||||
|
||||
async setVacationResponse(updates: Partial<VacationResponse>): Promise<void> {
|
||||
const response = await this.request([
|
||||
["VacationResponse/set", {
|
||||
accountId: this.accountId,
|
||||
update: {
|
||||
"singleton": updates,
|
||||
},
|
||||
}, "0"]
|
||||
], this.vacationUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "VacationResponse/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
if (result.notUpdated?.["singleton"]) {
|
||||
const error = result.notUpdated["singleton"];
|
||||
throw new Error(error.description || "Failed to update vacation response");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Failed to update vacation response");
|
||||
}
|
||||
|
||||
async createDraft(
|
||||
to: string[],
|
||||
subject: string,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
}
|
||||
@@ -167,6 +167,7 @@ export interface ContactCard {
|
||||
addresses?: Record<string, ContactAddress>;
|
||||
nicknames?: Record<string, ContactNickname>;
|
||||
notes?: Record<string, ContactNote>;
|
||||
members?: Record<string, boolean>;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}
|
||||
@@ -233,6 +234,16 @@ export interface AddressBookRights {
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
export interface VacationResponse {
|
||||
id: string;
|
||||
isEnabled: boolean;
|
||||
fromDate: string | null;
|
||||
toDate: string | null;
|
||||
subject: string;
|
||||
textBody: string;
|
||||
htmlBody: string | null;
|
||||
}
|
||||
|
||||
export interface EmailSubmission {
|
||||
id: string;
|
||||
identityId: string;
|
||||
|
||||
Reference in New Issue
Block a user