;
+ window.history.replaceState(
+ { ...baseState, [STATE_KEY]: listStored },
+ "",
+ );
+ // Now push the actual email state on top of the synthetic list entry.
+ window.history.pushState(newState, "");
+ } else {
+ // Replace the current entry on the very first run so we don't
+ // create an extra step the user has to back through to leave the app.
+ window.history.replaceState(newState, "");
+ }
} else {
window.history.pushState(newState, "");
}
diff --git a/hooks/use-identity-sync.ts b/hooks/use-identity-sync.ts
new file mode 100644
index 00000000..72bc5c37
--- /dev/null
+++ b/hooks/use-identity-sync.ts
@@ -0,0 +1,31 @@
+'use client';
+
+import { useEffect } from 'react';
+import { useAuthStore } from '@/stores/auth-store';
+
+// Re-sync identities every 30 minutes while the app is open
+const SYNC_INTERVAL_MS = 30 * 60 * 1000;
+
+export function useIdentitySync() {
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ const refreshIdentities = useAuthStore((s) => s.refreshIdentities);
+
+ useEffect(() => {
+ if (!isAuthenticated) return;
+
+ // Sync when the user returns to the tab (e.g. after adding an alias in Stalwart)
+ const handleVisibilityChange = () => {
+ if (document.visibilityState === 'visible') {
+ refreshIdentities();
+ }
+ };
+
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+ const interval = setInterval(refreshIdentities, SYNC_INTERVAL_MS);
+
+ return () => {
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
+ clearInterval(interval);
+ };
+ }, [isAuthenticated, refreshIdentities]);
+}
diff --git a/hooks/use-tag-drop.ts b/hooks/use-tag-drop.ts
index c90c68c1..61e7a391 100644
--- a/hooks/use-tag-drop.ts
+++ b/hooks/use-tag-drop.ts
@@ -78,14 +78,7 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us
const email = currentEmails.find(em => em.id === emailId);
const keywords = { ...(email?.keywords || {}) };
- // Remove old label/color keywords
- Object.keys(keywords).forEach(key => {
- if (key.startsWith("$label:") || key.startsWith("$color:")) {
- keywords[key] = false;
- }
- });
-
- // Add the new tag
+ // Add the tag without removing existing ones
keywords[`$label:${tagId}`] = true;
await client.updateEmailKeywords(emailId, keywords);
diff --git a/lib/__tests__/config-route.test.ts b/lib/__tests__/config-route.test.ts
index 3450cbf0..414ae7ae 100644
--- a/lib/__tests__/config-route.test.ts
+++ b/lib/__tests__/config-route.test.ts
@@ -1,3 +1,4 @@
+import { unlink, writeFileSync } from "fs";
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock NextResponse before importing the route
@@ -24,6 +25,7 @@ describe('config API route', () => {
delete process.env.OAUTH_CLIENT_ID;
delete process.env.OAUTH_ISSUER_URL;
delete process.env.SESSION_SECRET;
+ delete process.env.SESSION_SECRET_FILE;
delete process.env.SETTINGS_SYNC_ENABLED;
delete process.env.STALWART_FEATURES;
delete process.env.DEV_MOCK_JMAP;
@@ -128,6 +130,19 @@ describe('config API route', () => {
const config = await getConfig();
+ expect(config.rememberMeEnabled).toBe(true);
+ });
+
+ it('should enable rememberMe when SESSION_SECRET_FILE is set', async () => {
+ writeFileSync('./session-secret', 'test-secret');
+ process.env.SESSION_SECRET_FILE = './session-secret';
+
+ const config = await getConfig();
+
+ unlink('./session-secret', (err) => {
+ if (err) throw err;
+ });
+
expect(config.rememberMeEnabled).toBe(true);
});
@@ -138,6 +153,23 @@ describe('config API route', () => {
process.env.SESSION_SECRET = 'test-secret';
const config2 = await getConfig();
+ expect(config2.settingsSyncEnabled).toBe(true);
+ });
+
+ it('should enable settingsSync only when both SESSION_SECRET_FILE and SETTINGS_SYNC_ENABLED are set', async () => {
+ process.env.SETTINGS_SYNC_ENABLED = 'true';
+ const config1 = await getConfig();
+ expect(config1.settingsSyncEnabled).toBe(false);
+
+ writeFileSync('./session-secret', 'test-secret');
+ process.env.SESSION_SECRET_FILE = './session-secret';
+
+ const config2 = await getConfig();
+
+ unlink('./session-secret', (err) => {
+ if (err) throw err;
+ });
+
expect(config2.settingsSyncEnabled).toBe(true);
});
diff --git a/lib/admin/session.ts b/lib/admin/session.ts
index 3d91369f..ecde855e 100644
--- a/lib/admin/session.ts
+++ b/lib/admin/session.ts
@@ -1,6 +1,7 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
+import { readFileEnv } from '@/lib/read-file-env';
import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
import type { AdminSessionPayload } from './types';
@@ -11,7 +12,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer {
- const secret = process.env.SESSION_SECRET;
+ const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts
index ca0506c9..670bcc68 100644
--- a/lib/auth/crypto.ts
+++ b/lib/auth/crypto.ts
@@ -1,5 +1,6 @@
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { logger } from '@/lib/logger';
+import { readFileEnv } from '@/lib/read-file-env';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
@@ -8,7 +9,7 @@ const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
function getKey(): Buffer {
- const secret = process.env.SESSION_SECRET;
+ const secret = process.env.SESSION_SECRET || readFileEnv(process.env.SESSION_SECRET_FILE);
if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
diff --git a/lib/debug.ts b/lib/debug.ts
index 116235b2..d8049dac 100644
--- a/lib/debug.ts
+++ b/lib/debug.ts
@@ -104,7 +104,7 @@ export const debug = {
}
};
-const CATEGORY_KEYS = new Set
(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push']);
+const CATEGORY_KEYS = new Set(['jmap', 'calendar', 'tasks', 'auth', 'filters', 'email', 'push', 'contacts']);
function isCategoryKey(value: string): value is DebugCategory {
return CATEGORY_KEYS.has(value);
}
diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts
index ef3e28c5..2870a074 100644
--- a/lib/demo/demo-client.ts
+++ b/lib/demo/demo-client.ts
@@ -481,6 +481,12 @@ export class DemoJMAPClient implements IJMAPClient {
async getAddressBooks(): Promise { return [...this.data.addressBooks]; }
async getAllAddressBooks(): Promise { return [...this.data.addressBooks]; }
+ async createAddressBook(name: string): Promise {
+ const book: AddressBook = { id: `demo-book-${Date.now()}`, name };
+ this.data.addressBooks.push(book);
+ return book;
+ }
+
async updateAddressBook(addressBookId: string, updates: Partial): Promise {
const book = this.data.addressBooks.find(b => b.id === addressBookId);
if (book) Object.assign(book, updates);
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index 2ee86860..8b1a225b 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -178,6 +178,7 @@ export interface IJMAPClient {
getContactsAccountId(): string;
getAddressBooks(): Promise;
getAllAddressBooks(): Promise;
+ createAddressBook(name: string): Promise;
updateAddressBook(addressBookId: string, updates: Partial, targetAccountId?: string): Promise;
getContacts(addressBookId?: string): Promise;
getAllContacts(): Promise;
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index e2c5e950..03203c27 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -2868,6 +2868,27 @@ export class JMAPClient implements IJMAPClient {
}
}
+ async createAddressBook(name: string): Promise {
+ const accountId = this.getContactsAccountId();
+ const response = await this.request([
+ ["AddressBook/set", {
+ accountId,
+ create: { "new-book": { name } },
+ }, "0"]
+ ], this.contactUsing());
+
+ if (response.methodResponses?.[0]?.[0] === "AddressBook/set") {
+ const result = response.methodResponses[0][1];
+ const created = result.created?.["new-book"];
+ if (created) {
+ return { id: created.id, name, ...created } as AddressBook;
+ }
+ const err = result.notCreated?.["new-book"];
+ throw new Error(err?.description || "Failed to create address book");
+ }
+ throw new Error("Failed to create address book");
+ }
+
async updateAddressBook(addressBookId: string, updates: Partial, targetAccountId?: string): Promise {
const accountId = targetAccountId || this.getContactsAccountId();
// Only forward server-settable properties
diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts
index 30e3885f..3dc7efbe 100644
--- a/lib/jmap/types.ts
+++ b/lib/jmap/types.ts
@@ -203,12 +203,14 @@ export interface ContactCard {
}
export interface ContactName {
- components: NameComponent[];
+ components?: NameComponent[];
isOrdered?: boolean;
+ full?: string;
+ defaultSeparator?: string;
}
export interface NameComponent {
- kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional' | 'separator' | 'credential';
+ kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional' | 'separator' | 'credential' | 'title' | 'middle' | 'given2' | 'surname2' | 'generation';
value: string;
}
diff --git a/lib/oauth/token-exchange.ts b/lib/oauth/token-exchange.ts
index c1cbb443..abeb2348 100644
--- a/lib/oauth/token-exchange.ts
+++ b/lib/oauth/token-exchange.ts
@@ -1,8 +1,9 @@
import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import type { OAuthMetadata } from '@/lib/oauth/discovery';
+import { readFileEnv } from '@/lib/read-file-env';
-const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
+const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
export function getRequiredConfig() {
const clientId = process.env.OAUTH_CLIENT_ID;
diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts
index 2a2d4353..4ca7144e 100644
--- a/lib/plugin-api.ts
+++ b/lib/plugin-api.ts
@@ -22,7 +22,7 @@ import {
taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
- sidebarAppHooks,
+ sidebarAppHooks, avatarHooks,
} from './plugin-hooks';
import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
@@ -314,6 +314,8 @@ export interface PluginHooksAPI {
onSidebarAppOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable;
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
+ // Avatar
+ onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
}
// --- Permission mapping for hooks ----------------------------
@@ -417,6 +419,8 @@ const HOOK_PERMISSIONS: Record = {
// Sidebar Apps
onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe',
onSidebarAppChange: 'ui:observe',
+ // Avatar
+ onAvatarResolve: 'email:read',
};
// Map hook names → actual HookBus instances
@@ -463,6 +467,8 @@ const HOOK_BUSES: Record | undefined): string | null {
- if (!keywords) return null;
-
+export function getEmailColorTags(keywords: Record | undefined): string[] {
+ if (!keywords) return [];
+ const tags: string[] = [];
for (const key of Object.keys(keywords)) {
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
- return key.startsWith(KEYWORD_PREFIX)
- ? key.slice(KEYWORD_PREFIX.length)
- : key.slice(KEYWORD_PREFIX_LEGACY.length);
+ tags.push(
+ key.startsWith(KEYWORD_PREFIX)
+ ? key.slice(KEYWORD_PREFIX.length)
+ : key.slice(KEYWORD_PREFIX_LEGACY.length)
+ );
}
}
+ return tags;
+}
- return null;
+/**
+ * Gets label/color tag from email keywords (if any).
+ * Reads both the current $label: prefix and the legacy $color: prefix.
+ * @deprecated Use getEmailColorTags for multi-tag support.
+ */
+export function getEmailColorTag(keywords: Record | undefined): string | null {
+ const tags = getEmailColorTags(keywords);
+ return tags.length > 0 ? tags[0] : null;
}
/**
diff --git a/lib/vcard.ts b/lib/vcard.ts
index 3b6fa97c..eafb32bf 100644
--- a/lib/vcard.ts
+++ b/lib/vcard.ts
@@ -527,7 +527,7 @@ function buildContact(raw: Record): ContactCard | null {
}
}
- const hasName = card.name && card.name.components.length > 0;
+ const hasName = card.name && (card.name.components?.length ?? 0) > 0 || !!card.name?.full;
const hasEmail = card.emails && Object.keys(card.emails).length > 0;
if (!hasName && !hasEmail && card.kind !== "group") return null;
@@ -564,7 +564,7 @@ function generateSingleVCard(contact: ContactCard): string {
const suffix = components.find(c => c.kind === "suffix")?.value || "";
const additional = components.find(c => c.kind === "additional")?.value || "";
- const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ");
+ const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || "";
if (fn) {
lines.push(`FN:${encodeValue(fn)}`);
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
diff --git a/locales/de/common.json b/locales/de/common.json
index bc6a92e6..0bce3a1a 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -494,7 +494,13 @@
"close_draft_message": "Sie haben ungespeicherte Änderungen. Möchten Sie diese als Entwurf speichern oder verwerfen?",
"save_draft": "Entwurf speichern",
"drop_files": "Dateien zum Anhängen ablegen",
- "show_less": "Weniger anzeigen"
+ "show_less": "Weniger anzeigen",
+ "forgot_attachment": {
+ "title": "Haben Sie den Anhang vergessen?",
+ "message": "Ihre Nachricht enthält \"{keyword}\", aber es ist keine Datei angehängt. Trotzdem senden?",
+ "send_anyway": "Trotzdem senden",
+ "back": "Zurück zur Bearbeitung"
+ }
},
"confirm_dialog": {
"confirm": "Bestätigen",
@@ -914,6 +920,15 @@
"button": "Als Standard festlegen",
"success": "Browser wurde aufgefordert, als Standard festzulegen",
"error": "Ihr Browser unterstützt diese Funktion nicht"
+ },
+ "attachment_reminder": {
+ "label": "Erinnerung an Anhang",
+ "description": "Warnung anzeigen, wenn die Nachricht Anhänge erwähnt, aber keine angehängt sind",
+ "keywords_label": "Schlüsselwörter",
+ "keywords_description": "Wörter oder Phrasen, die die Erinnerung auslösen",
+ "add_placeholder": "Schlüsselwort hinzufügen...",
+ "add": "Hinzufügen",
+ "remove": "Entfernen"
}
},
"composer": {
diff --git a/locales/en/common.json b/locales/en/common.json
index 9ecc5d70..8a8a6549 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -494,7 +494,13 @@
"smime_unlock_title": "Unlock S/MIME Key",
"smime_unlock_message": "Enter the passphrase to unlock your S/MIME signing key.",
"smime_unlock_button": "Unlock",
- "smime_passphrase_placeholder": "Passphrase"
+ "smime_passphrase_placeholder": "Passphrase",
+ "forgot_attachment": {
+ "title": "Did you forget an attachment?",
+ "message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
+ "send_anyway": "Send anyway",
+ "back": "Back to editing"
+ }
},
"confirm_dialog": {
"confirm": "Confirm",
@@ -887,7 +893,10 @@
"remove": "Remove",
"close": "Close",
"invalid_email": "Please enter a valid email address",
- "already_added": "This sender is already trusted"
+ "already_added": "This sender is already trusted",
+ "save_error": "Failed to save — check the Contacts debug log for details",
+ "use_address_book_label": "Sync with address book",
+ "use_address_book_description": "Store trusted senders in a dedicated \"Trusted Senders\" address book so they sync across all your devices"
},
"hover_actions": {
"label": "Quick Hover Actions",
@@ -914,6 +923,15 @@
"button": "Set as Default",
"success": "Browser prompted to set as default",
"error": "Your browser does not support this feature"
+ },
+ "attachment_reminder": {
+ "label": "Attachment Reminder",
+ "description": "Warn before sending when your message mentions attachments but none are attached",
+ "keywords_label": "Trigger keywords",
+ "keywords_description": "Words or phrases that trigger the reminder when found in your message",
+ "add_placeholder": "Add keyword...",
+ "add": "Add",
+ "remove": "Remove"
}
},
"composer": {
@@ -1182,7 +1200,9 @@
"email": "Email Viewing",
"email_description": "Email rendering, TNEF processing, and mark-as-read",
"push": "Push Notifications",
- "push_description": "Push notification setup and delivery"
+ "push_description": "Push notification setup and delivery",
+ "contacts": "Contacts & Address Books",
+ "contacts_description": "Contact sync, address book operations, and trusted senders"
},
"settings_sync": {
"label": "Settings Sync",
diff --git a/locales/es/common.json b/locales/es/common.json
index c4aed84c..d83a17bc 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -494,7 +494,13 @@
"close_draft_message": "Tiene cambios sin guardar. ¿Desea guardar esto como borrador o descartarlo?",
"save_draft": "Guardar borrador",
"drop_files": "Suelta archivos para adjuntar",
- "show_less": "Mostrar menos"
+ "show_less": "Mostrar menos",
+ "forgot_attachment": {
+ "title": "Did you forget an attachment?",
+ "message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
+ "send_anyway": "Send anyway",
+ "back": "Back to editing"
+ }
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -914,6 +920,15 @@
"button": "Establecer como predeterminado",
"success": "El navegador solicitó establecer como predeterminado",
"error": "Su navegador no admite esta función"
+ },
+ "attachment_reminder": {
+ "label": "Attachment Reminder",
+ "description": "Warn before sending when your message mentions attachments but none are attached",
+ "keywords_label": "Trigger keywords",
+ "keywords_description": "Words or phrases that trigger the reminder when found in your message",
+ "add_placeholder": "Add keyword...",
+ "add": "Add",
+ "remove": "Remove"
}
},
"composer": {
diff --git a/locales/fr/common.json b/locales/fr/common.json
index a2f7da9a..855f4976 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -494,7 +494,13 @@
"close_draft_message": "Vous avez des modifications non enregistrées. Voulez-vous enregistrer comme brouillon ou supprimer ?",
"save_draft": "Enregistrer le brouillon",
"drop_files": "Déposez les fichiers à joindre",
- "show_less": "Afficher moins"
+ "show_less": "Afficher moins",
+ "forgot_attachment": {
+ "title": "Did you forget an attachment?",
+ "message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
+ "send_anyway": "Send anyway",
+ "back": "Back to editing"
+ }
},
"confirm_dialog": {
"confirm": "Confirmer",
@@ -914,6 +920,15 @@
"button": "Définir par défaut",
"success": "Le navigateur a été invité à définir par défaut",
"error": "Votre navigateur ne prend pas en charge cette fonctionnalité"
+ },
+ "attachment_reminder": {
+ "label": "Attachment Reminder",
+ "description": "Warn before sending when your message mentions attachments but none are attached",
+ "keywords_label": "Trigger keywords",
+ "keywords_description": "Words or phrases that trigger the reminder when found in your message",
+ "add_placeholder": "Add keyword...",
+ "add": "Add",
+ "remove": "Remove"
}
},
"composer": {
diff --git a/locales/it/common.json b/locales/it/common.json
index 714b98ea..e48c2294 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -494,7 +494,13 @@
"close_draft_message": "Hai modifiche non salvate. Vuoi salvare come bozza o eliminare?",
"save_draft": "Salva bozza",
"drop_files": "Trascina i file per allegarli",
- "show_less": "Mostra meno"
+ "show_less": "Mostra meno",
+ "forgot_attachment": {
+ "title": "Did you forget an attachment?",
+ "message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
+ "send_anyway": "Send anyway",
+ "back": "Back to editing"
+ }
},
"confirm_dialog": {
"confirm": "Conferma",
@@ -914,6 +920,15 @@
"button": "Imposta come predefinito",
"success": "Il browser ha chiesto di impostare come predefinito",
"error": "Il tuo browser non supporta questa funzionalità"
+ },
+ "attachment_reminder": {
+ "label": "Attachment Reminder",
+ "description": "Warn before sending when your message mentions attachments but none are attached",
+ "keywords_label": "Trigger keywords",
+ "keywords_description": "Words or phrases that trigger the reminder when found in your message",
+ "add_placeholder": "Add keyword...",
+ "add": "Add",
+ "remove": "Remove"
}
},
"composer": {
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 32ec986c..fc40cc62 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -494,7 +494,13 @@
"close_draft_message": "未保存の変更があります。下書きとして保存しますか、それとも破棄しますか?",
"save_draft": "下書きを保存",
"drop_files": "ファイルをドロップして添付",
- "show_less": "折りたたむ"
+ "show_less": "折りたたむ",
+ "forgot_attachment": {
+ "title": "Did you forget an attachment?",
+ "message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
+ "send_anyway": "Send anyway",
+ "back": "Back to editing"
+ }
},
"confirm_dialog": {
"confirm": "確認",
@@ -914,6 +920,15 @@
"button": "既定に設定",
"success": "ブラウザに既定として設定するよう要求しました",
"error": "お使いのブラウザはこの機能をサポートしていません"
+ },
+ "attachment_reminder": {
+ "label": "Attachment Reminder",
+ "description": "Warn before sending when your message mentions attachments but none are attached",
+ "keywords_label": "Trigger keywords",
+ "keywords_description": "Words or phrases that trigger the reminder when found in your message",
+ "add_placeholder": "Add keyword...",
+ "add": "Add",
+ "remove": "Remove"
}
},
"composer": {
diff --git a/locales/ko/common.json b/locales/ko/common.json
index cad0abd5..f64ed071 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -494,7 +494,13 @@
"smime_unlock_title": "S/MIME 키 잠금 해제",
"smime_unlock_message": "S/MIME 서명 키의 잠금을 해제하려면 비밀번호를 입력해 주세요.",
"smime_unlock_button": "잠금 해제",
- "smime_passphrase_placeholder": "비밀번호"
+ "smime_passphrase_placeholder": "비밀번호",
+ "forgot_attachment": {
+ "title": "Did you forget an attachment?",
+ "message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
+ "send_anyway": "Send anyway",
+ "back": "Back to editing"
+ }
},
"confirm_dialog": {
"confirm": "확인",
@@ -914,6 +920,15 @@
"button": "기본값으로 설정",
"success": "브라우저에서 기본 설정 팝업이 뜰 거예요",
"error": "이 브라우저에서는 이 기능을 지원하지 않아요"
+ },
+ "attachment_reminder": {
+ "label": "Attachment Reminder",
+ "description": "Warn before sending when your message mentions attachments but none are attached",
+ "keywords_label": "Trigger keywords",
+ "keywords_description": "Words or phrases that trigger the reminder when found in your message",
+ "add_placeholder": "Add keyword...",
+ "add": "Add",
+ "remove": "Remove"
}
},
"composer": {
diff --git a/locales/lv/common.json b/locales/lv/common.json
index ea3a22b7..448cfab7 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -493,7 +493,13 @@
"smime_unlock_title": "Atbloķēt S/MIME atslēgu",
"smime_unlock_message": "Ievadiet paroli, lai atbloķētu savu S/MIME parakstīšanas atslēgu.",
"smime_unlock_button": "Atbloķēt",
- "smime_passphrase_placeholder": "Parole"
+ "smime_passphrase_placeholder": "Parole",
+ "forgot_attachment": {
+ "title": "Did you forget an attachment?",
+ "message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
+ "send_anyway": "Send anyway",
+ "back": "Back to editing"
+ }
},
"confirm_dialog": {
"confirm": "Apstiprināt",
@@ -913,6 +919,15 @@
"button": "Iestatīt kā noklusējumu",
"success": "Pārlūkam nosūtīts pieprasījums iestatīt kā noklusējumu",
"error": "Jūsu pārlūks neatbalsta šo funkciju"
+ },
+ "attachment_reminder": {
+ "label": "Attachment Reminder",
+ "description": "Warn before sending when your message mentions attachments but none are attached",
+ "keywords_label": "Trigger keywords",
+ "keywords_description": "Words or phrases that trigger the reminder when found in your message",
+ "add_placeholder": "Add keyword...",
+ "add": "Add",
+ "remove": "Remove"
}
},
"composer": {
diff --git a/locales/nl/common.json b/locales/nl/common.json
index e180b8f1..685b19fa 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -494,7 +494,13 @@
"close_draft_message": "U heeft niet-opgeslagen wijzigingen. Wilt u dit als concept opslaan of verwijderen?",
"save_draft": "Concept opslaan",
"drop_files": "Sleep bestanden om bij te voegen",
- "show_less": "Minder tonen"
+ "show_less": "Minder tonen",
+ "forgot_attachment": {
+ "title": "Did you forget an attachment?",
+ "message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
+ "send_anyway": "Send anyway",
+ "back": "Back to editing"
+ }
},
"confirm_dialog": {
"confirm": "Bevestigen",
@@ -914,6 +920,15 @@
"button": "Instellen als standaard",
"success": "Browser gevraagd om als standaard in te stellen",
"error": "Uw browser ondersteunt deze functie niet"
+ },
+ "attachment_reminder": {
+ "label": "Attachment Reminder",
+ "description": "Warn before sending when your message mentions attachments but none are attached",
+ "keywords_label": "Trigger keywords",
+ "keywords_description": "Words or phrases that trigger the reminder when found in your message",
+ "add_placeholder": "Add keyword...",
+ "add": "Add",
+ "remove": "Remove"
}
},
"composer": {
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 3b64203f..ed1fb15a 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -494,7 +494,13 @@
"smime_unlock_title": "Odblokuj klucz S/MIME",
"smime_unlock_message": "Wprowadź hasło, aby odblokować klucz podpisywania S/MIME.",
"smime_unlock_button": "Odblokuj",
- "smime_passphrase_placeholder": "Hasło"
+ "smime_passphrase_placeholder": "Hasło",
+ "forgot_attachment": {
+ "title": "Did you forget an attachment?",
+ "message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
+ "send_anyway": "Send anyway",
+ "back": "Back to editing"
+ }
},
"confirm_dialog": {
"confirm": "Potwierdź",
@@ -916,6 +922,15 @@
"button": "Ustaw jako domyślny",
"success": "Przeglądarka poprosiła o ustawienie jako domyślnego",
"error": "Twoja przeglądarka nie obsługuje tej funkcji"
+ },
+ "attachment_reminder": {
+ "label": "Attachment Reminder",
+ "description": "Warn before sending when your message mentions attachments but none are attached",
+ "keywords_label": "Trigger keywords",
+ "keywords_description": "Words or phrases that trigger the reminder when found in your message",
+ "add_placeholder": "Add keyword...",
+ "add": "Add",
+ "remove": "Remove"
}
},
"composer": {
diff --git a/locales/pt/common.json b/locales/pt/common.json
index aa006639..468980a1 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -494,7 +494,13 @@
"close_draft_message": "Você tem alterações não salvas. Deseja salvar como rascunho ou descartar?",
"save_draft": "Salvar rascunho",
"drop_files": "Solte arquivos para anexar",
- "show_less": "Mostrar menos"
+ "show_less": "Mostrar menos",
+ "forgot_attachment": {
+ "title": "Did you forget an attachment?",
+ "message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
+ "send_anyway": "Send anyway",
+ "back": "Back to editing"
+ }
},
"confirm_dialog": {
"confirm": "Confirmar",
@@ -914,6 +920,15 @@
"button": "Definir como padrão",
"success": "O navegador solicitou definir como padrão",
"error": "Seu navegador não suporta esta funcionalidade"
+ },
+ "attachment_reminder": {
+ "label": "Attachment Reminder",
+ "description": "Warn before sending when your message mentions attachments but none are attached",
+ "keywords_label": "Trigger keywords",
+ "keywords_description": "Words or phrases that trigger the reminder when found in your message",
+ "add_placeholder": "Add keyword...",
+ "add": "Add",
+ "remove": "Remove"
}
},
"composer": {
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 34e082e8..fd705832 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -494,7 +494,13 @@
"smime_unlock_title": "Разблокировать ключ S/MIME",
"smime_unlock_message": "Введите парольную фразу для разблокировки вашего ключа подписи S/MIME.",
"smime_unlock_button": "Разблокировать",
- "smime_passphrase_placeholder": "Парольная фраза"
+ "smime_passphrase_placeholder": "Парольная фраза",
+ "forgot_attachment": {
+ "title": "Did you forget an attachment?",
+ "message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
+ "send_anyway": "Send anyway",
+ "back": "Back to editing"
+ }
},
"confirm_dialog": {
"confirm": "Подтвердить",
@@ -914,6 +920,15 @@
"button": "Установить по умолчанию",
"success": "Браузер запрошен для установки по умолчанию",
"error": "Ваш браузер не поддерживает эту функцию"
+ },
+ "attachment_reminder": {
+ "label": "Attachment Reminder",
+ "description": "Warn before sending when your message mentions attachments but none are attached",
+ "keywords_label": "Trigger keywords",
+ "keywords_description": "Words or phrases that trigger the reminder when found in your message",
+ "add_placeholder": "Add keyword...",
+ "add": "Add",
+ "remove": "Remove"
}
},
"composer": {
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 2fd33cef..48a47efb 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -494,7 +494,13 @@
"smime_unlock_title": "解锁 S/MIME 密钥",
"smime_unlock_message": "输入密码以解锁您的 S/MIME 签名密钥。",
"smime_unlock_button": "解锁",
- "smime_passphrase_placeholder": "输入密码"
+ "smime_passphrase_placeholder": "输入密码",
+ "forgot_attachment": {
+ "title": "Did you forget an attachment?",
+ "message": "Your message mentions \"{keyword}\" but no file is attached. Send anyway?",
+ "send_anyway": "Send anyway",
+ "back": "Back to editing"
+ }
},
"confirm_dialog": {
"confirm": "确认",
@@ -914,6 +920,15 @@
"button": "设为默认",
"success": "浏览器已提示设置为默认",
"error": "您的浏览器不支持此功能"
+ },
+ "attachment_reminder": {
+ "label": "Attachment Reminder",
+ "description": "Warn before sending when your message mentions attachments but none are attached",
+ "keywords_label": "Trigger keywords",
+ "keywords_description": "Words or phrases that trigger the reminder when found in your message",
+ "add_placeholder": "Add keyword...",
+ "add": "Add",
+ "remove": "Remove"
}
},
"composer": {
diff --git a/package-lock.json b/package-lock.json
index af747cc5..4233198d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
- "version": "1.4.10",
+ "version": "1.4.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
- "version": "1.4.10",
+ "version": "1.4.13",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.18",
diff --git a/package.json b/package.json
index d51bf1bb..b2133cf4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
- "version": "1.4.11",
+ "version": "1.4.13",
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail ",
"license": "AGPL-3.0-only",
diff --git a/public/branding/Bulwark_Icon_App.svg b/public/branding/Bulwark_Icon_App.svg
new file mode 100644
index 00000000..4b4fae76
--- /dev/null
+++ b/public/branding/Bulwark_Icon_App.svg
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/public/icon-192x192.png b/public/icon-192x192.png
new file mode 100644
index 00000000..1129a436
Binary files /dev/null and b/public/icon-192x192.png differ
diff --git a/public/icon-512x512.png b/public/icon-512x512.png
new file mode 100644
index 00000000..8d21a585
Binary files /dev/null and b/public/icon-512x512.png differ
diff --git a/public/icon-maskable-dark-192x192.png b/public/icon-maskable-dark-192x192.png
new file mode 100644
index 00000000..4f091837
Binary files /dev/null and b/public/icon-maskable-dark-192x192.png differ
diff --git a/public/icon-maskable-dark-512x512.png b/public/icon-maskable-dark-512x512.png
new file mode 100644
index 00000000..7b4d30da
Binary files /dev/null and b/public/icon-maskable-dark-512x512.png differ
diff --git a/public/icon-maskable-light-192x192.png b/public/icon-maskable-light-192x192.png
new file mode 100644
index 00000000..6c878d86
Binary files /dev/null and b/public/icon-maskable-light-192x192.png differ
diff --git a/public/icon-maskable-light-512x512.png b/public/icon-maskable-light-512x512.png
new file mode 100644
index 00000000..3d269cc3
Binary files /dev/null and b/public/icon-maskable-light-512x512.png differ
diff --git a/public/manifest.json b/public/manifest.json
index b483c230..b1ce3771 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -22,16 +22,32 @@
"purpose": "any"
},
{
- "src": "/icon-192x192.png",
+ "src": "/icon-maskable-light-192x192.png",
"sizes": "192x192",
"type": "image/png",
- "purpose": "maskable"
+ "purpose": "maskable",
+ "media": "(prefers-color-scheme: light)"
},
{
- "src": "/icon-512x512.png",
+ "src": "/icon-maskable-light-512x512.png",
"sizes": "512x512",
"type": "image/png",
- "purpose": "maskable"
+ "purpose": "maskable",
+ "media": "(prefers-color-scheme: light)"
+ },
+ {
+ "src": "/icon-maskable-dark-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable",
+ "media": "(prefers-color-scheme: dark)"
+ },
+ {
+ "src": "/icon-maskable-dark-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable",
+ "media": "(prefers-color-scheme: dark)"
}
],
"categories": ["productivity"],
diff --git a/public/screenshot-1280x720.png b/public/screenshot-1280x720.png
new file mode 100644
index 00000000..424a520d
Binary files /dev/null and b/public/screenshot-1280x720.png differ
diff --git a/public/screenshot-540x720.png b/public/screenshot-540x720.png
new file mode 100644
index 00000000..35ebaa39
Binary files /dev/null and b/public/screenshot-540x720.png differ
diff --git a/public/sw.js b/public/sw.js
index db44454a..4110d0c8 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,26 +1,16 @@
/* eslint-disable no-undef */
-// Self-destructing service worker.
-//
-// The previous version of this file used a cache-first strategy with no
-// dev-mode guard, which pinned stale JS/HTML chunks until a hard reload.
-// This replacement unregisters itself and wipes all caches as soon as the
-// browser picks it up. Browsers re-fetch sw.js on every navigation to check
-// for updates, so any client running the old worker will swap to this one
-// on their next page load and then lose the worker entirely.
+// Minimal service worker – satisfies the PWA installability requirement
+// without caching any assets. All requests fall through to the network,
+// so there is no risk of serving stale chunks after a deployment.
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
- event.waitUntil(
- (async () => {
- const cacheNames = await caches.keys();
- await Promise.all(cacheNames.map((name) => caches.delete(name)));
- await self.registration.unregister();
- const clients = await self.clients.matchAll({ type: "window" });
- clients.forEach((client) => client.navigate(client.url));
- })(),
- );
+ event.waitUntil(self.clients.claim());
});
+
+// Network-only fetch handler – no caching.
+self.addEventListener("fetch", () => {});
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index aa421232..3d141452 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -47,6 +47,7 @@ interface AuthState {
checkAuth: () => Promise;
clearError: () => void;
syncIdentities: () => void;
+ refreshIdentities: () => Promise;
getClientForAccount: (accountId: string) => JMAPClient | undefined;
}
@@ -1513,6 +1514,18 @@ export const useAuthStore = create()(
set({ identities, primaryIdentity });
},
+ refreshIdentities: async () => {
+ const { client, username } = get();
+ if (!client || !username) return;
+ try {
+ const rawIdentities = await client.getIdentities();
+ const { identities, primaryIdentity } = loadIdentities(rawIdentities, username);
+ set({ identities, primaryIdentity });
+ } catch {
+ // Silently fail — background sync should not surface errors to the user
+ }
+ },
+
getClientForAccount: (accountId: string) => {
return clients.get(accountId);
},
diff --git a/stores/contact-store.ts b/stores/contact-store.ts
index d516e621..ee80767d 100644
--- a/stores/contact-store.ts
+++ b/stores/contact-store.ts
@@ -3,18 +3,28 @@ import { persist } from 'zustand/middleware';
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { generateUUID } from '@/lib/utils';
+import { debug } from '@/lib/debug';
export function getContactDisplayName(contact: ContactCard): string {
- if (contact.name?.components) {
- const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
- const surname = contact.name.components.find(c => c.kind === 'surname')?.value || '';
- const full = [given, surname].filter(Boolean).join(' ');
- if (full) return full;
+ if (contact.name) {
+ // Try given + surname from components first
+ if (contact.name.components && contact.name.components.length > 0) {
+ const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
+ const surname = contact.name.components.find(c => c.kind === 'surname')?.value || '';
+ const full = [given, surname].filter(Boolean).join(' ');
+ if (full) return full;
+ }
+ // Fall back to name.full (RFC 9553 — used by Stalwart and other JMAP servers)
+ if (contact.name.full) return contact.name.full;
}
if (contact.nicknames) {
const nick = Object.values(contact.nicknames)[0];
if (nick?.name) return nick.name;
}
+ if (contact.organizations) {
+ const org = Object.values(contact.organizations)[0];
+ if (org?.name) return org.name;
+ }
if (contact.emails) {
const email = Object.values(contact.emails)[0];
if (email?.address) return email.address;
@@ -35,6 +45,8 @@ export function getContactPhotoUri(contact: ContactCard): string | undefined {
return undefined;
}
+export const TRUSTED_SENDERS_BOOK_NAME = 'Trusted Senders';
+
interface ContactStore {
contacts: ContactCard[];
addressBooks: AddressBook[];
@@ -44,6 +56,12 @@ interface ContactStore {
error: string | null;
supportsSync: boolean;
+ // Trusted senders address book cache (runtime only, not persisted)
+ trustedSenderEmails: string[];
+ trustedSendersBookId: string | null;
+ trustedSendersLoaded: boolean;
+ trustedSendersLoading: boolean;
+
selectedContactIds: Set;
lastSelectedContactId: string | null;
activeTab: 'all' | 'groups';
@@ -86,6 +104,12 @@ interface ContactStore {
renameKeyword: (client: IJMAPClient | null, oldKeyword: string, newKeyword: string) => Promise;
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise;
+
+ // Trusted senders address book
+ loadTrustedSendersBook: (client: IJMAPClient) => Promise;
+ addToTrustedSendersBook: (client: IJMAPClient, email: string) => Promise;
+ removeFromTrustedSendersBook: (client: IJMAPClient, email: string) => Promise;
+ isTrustedAddressBookSender: (email: string) => boolean;
}
export const useContactStore = create()(
@@ -130,6 +154,10 @@ export const useContactStore = create()(
isLoading: false,
error: null,
supportsSync: false,
+ trustedSenderEmails: [],
+ trustedSendersBookId: null,
+ trustedSendersLoaded: false,
+ trustedSendersLoading: false,
selectedContactIds: new Set(),
lastSelectedContactId: null,
activeTab: 'all' as const,
@@ -658,6 +686,74 @@ export const useContactStore = create()(
}
},
+ loadTrustedSendersBook: async (client) => {
+ if (get().trustedSendersLoading) return;
+ set({ trustedSendersLoading: true });
+ try {
+ debug.log('contacts', 'Loading trusted senders address book');
+ const books = await client.getAddressBooks();
+ let book = books.find(b => b.name === TRUSTED_SENDERS_BOOK_NAME);
+ if (!book) {
+ debug.log('contacts', 'Creating new trusted senders address book');
+ book = await client.createAddressBook(TRUSTED_SENDERS_BOOK_NAME);
+ }
+ const bookId = book.id;
+ debug.log('contacts', 'Trusted senders book id:', bookId);
+ const contacts = await client.getContacts(bookId);
+ debug.log('contacts', 'Loaded', contacts.length, 'trusted sender contacts');
+ const emails = contacts.flatMap(c =>
+ c.emails ? Object.values(c.emails).map(e => e.address.toLowerCase().trim()) : []
+ ).filter(Boolean);
+ set({ trustedSendersBookId: bookId, trustedSenderEmails: emails, trustedSendersLoaded: true, trustedSendersLoading: false });
+ } catch (error) {
+ debug.error('Failed to load trusted senders address book:', error);
+ set({ trustedSendersLoaded: true, trustedSendersLoading: false });
+ }
+ },
+
+ addToTrustedSendersBook: async (client, email) => {
+ const normalizedEmail = email.toLowerCase().trim();
+ const { trustedSenderEmails } = get();
+ if (trustedSenderEmails.includes(normalizedEmail)) return;
+
+ let bookId = get().trustedSendersBookId;
+ if (!bookId) {
+ await get().loadTrustedSendersBook(client);
+ bookId = get().trustedSendersBookId;
+ }
+ if (!bookId) throw new Error('Could not find or create trusted senders address book');
+
+ debug.log('contacts', 'Adding trusted sender:', normalizedEmail, 'to book:', bookId);
+ await client.createContact({
+ addressBookIds: { [bookId]: true },
+ emails: { email: { address: normalizedEmail } },
+ });
+ set((state) => ({ trustedSenderEmails: [...state.trustedSenderEmails, normalizedEmail] }));
+ debug.log('contacts', 'Trusted sender added successfully');
+ },
+
+ removeFromTrustedSendersBook: async (client, email) => {
+ const normalizedEmail = email.toLowerCase().trim();
+ const { trustedSendersBookId } = get();
+ if (!trustedSendersBookId) return;
+
+ debug.log('contacts', 'Removing trusted sender:', normalizedEmail);
+ const contacts = await client.getContacts(trustedSendersBookId);
+ const match = contacts.find(c =>
+ c.emails && Object.values(c.emails).some(e => e.address.toLowerCase().trim() === normalizedEmail)
+ );
+ if (match) {
+ await client.deleteContact(match.id);
+ debug.log('contacts', 'Trusted sender removed');
+ }
+ set((state) => ({ trustedSenderEmails: state.trustedSenderEmails.filter(e => e !== normalizedEmail) }));
+ },
+
+ isTrustedAddressBookSender: (email) => {
+ const normalizedEmail = email.toLowerCase().trim();
+ return get().trustedSenderEmails.includes(normalizedEmail);
+ },
+
importContacts: async (client, contacts) => {
const { supportsSync } = get();
let imported = 0;
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 69a60616..527a2453 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -312,7 +312,9 @@ export const useEmailStore = create((set, get) => ({
const { selectedKeyword } = get();
const keywordFilter = selectedKeyword ? `$label:${selectedKeyword}` : undefined;
- const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
+ // When filtering by tag, omit the mailbox constraint so emails across
+ // all folders that carry the tag are returned.
+ const result = await client.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
@@ -372,7 +374,8 @@ export const useEmailStore = create((set, get) => ({
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
- result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
+ // When filtering by tag, omit the mailbox constraint (same rationale as fetchEmails).
+ result = await client.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
}
// Use fresh state when merging to avoid overwriting concurrent updates
@@ -1242,7 +1245,18 @@ export const useEmailStore = create((set, get) => ({
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
- const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
+ // Respect active search filters / query so that a push-triggered refresh
+ // does not silently replace a filtered list with an unfiltered one.
+ const { searchQuery, searchFilters } = get();
+ const hasFilters = !isFilterEmpty(searchFilters);
+
+ let result;
+ if (hasFilters || searchQuery) {
+ const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
+ result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
+ } else {
+ result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
+ }
const currentEmails = get().emails;
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index e500a0a0..8a5f5fb9 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -49,7 +49,7 @@ export const ALL_HOVER_ACTIONS: { id: HoverAction; labelKey: string }[] = [
{ id: 'spam', labelKey: 'spam' },
];
-export type DebugCategory = 'jmap' | 'calendar' | 'tasks' | 'auth' | 'filters' | 'email' | 'push';
+export type DebugCategory = 'jmap' | 'calendar' | 'tasks' | 'auth' | 'filters' | 'email' | 'push' | 'contacts';
export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [
{ id: 'jmap', labelKey: 'jmap' },
@@ -59,6 +59,7 @@ export const ALL_DEBUG_CATEGORIES: { id: DebugCategory; labelKey: string }[] = [
{ id: 'filters', labelKey: 'filters' },
{ id: 'email', labelKey: 'email' },
{ id: 'push', labelKey: 'push' },
+ { id: 'contacts', labelKey: 'contacts' },
];
export interface KeywordDefinition {
@@ -140,6 +141,7 @@ interface SettingsState {
// Privacy & Security
sessionTimeout: number; // minutes (0 = never)
trustedSenders: string[]; // Email addresses that can load external content
+ trustedSendersAddressBook: boolean; // Store trusted senders in a dedicated JMAP address book
// Filters
expandedFilterView: boolean;
@@ -185,6 +187,10 @@ interface SettingsState {
// Keywords (labels/tags)
emailKeywords: KeywordDefinition[];
+ // Attachment Reminder
+ attachmentReminderEnabled: boolean;
+ attachmentReminderKeywords: string[];
+
// Sidebar Apps
sidebarApps: SidebarApp[];
keepAppsLoaded: boolean;
@@ -269,6 +275,7 @@ const DEFAULT_SETTINGS = {
// Privacy & Security
sessionTimeout: 0, // Never
trustedSenders: [] as string[],
+ trustedSendersAddressBook: false,
// Filters
expandedFilterView: false,
@@ -314,6 +321,37 @@ const DEFAULT_SETTINGS = {
// Keywords
emailKeywords: DEFAULT_KEYWORDS,
+ // Attachment Reminder
+ attachmentReminderEnabled: true,
+ attachmentReminderKeywords: [
+ // English
+ 'attached', 'attachment', 'attachments', 'see attached', 'find attached', 'please find attached',
+ // German
+ 'angehängt', 'anhang', 'anbei', 'im anhang',
+ // French
+ 'ci-joint', 'pièce jointe',
+ // Spanish
+ 'adjunto', 'adjunta', 'en adjunto',
+ // Italian
+ 'allegato', 'in allegato',
+ // Dutch
+ 'bijgevoegd', 'bijlage',
+ // Portuguese
+ 'em anexo', 'anexo',
+ // Polish
+ 'w załączniku',
+ // Russian
+ 'во вложении',
+ // Japanese
+ '添付',
+ // Chinese
+ '附件',
+ // Korean
+ '첨부',
+ // Latvian
+ 'pielikumā',
+ ] as string[],
+
// Sidebar Apps
sidebarApps: [] as SidebarApp[],
keepAppsLoaded: false,
@@ -412,6 +450,8 @@ export const useSettingsStore = create()(
senderFavicons: state.senderFavicons,
folderIcons: state.folderIcons,
emailKeywords: state.emailKeywords,
+ attachmentReminderEnabled: state.attachmentReminderEnabled,
+ attachmentReminderKeywords: state.attachmentReminderKeywords,
sidebarApps: state.sidebarApps,
keepAppsLoaded: state.keepAppsLoaded,
debugMode: state.debugMode,