diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a261a35..169df843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## 1.4.13 (2026-04-12) + +Thank you for your donations: + +**One-time** +- [@boris22100](https://github.com/boris22100) +- [@mkorthaus-private](https://github.com/mkorthaus-private) + +**Monthly** +- _You? [Become a sponsor!](https://github.com/sponsors/bulwarkmail)_ + +### Features + +- **Contacts**: Store trusted senders in a dedicated JMAP address book (#176) +- **Email**: Warn on send when attachment keyword found but no file attached (#172) +- **Email**: Enable keyword reordering (#174) and multi-tag support per email (#173) +- **PWA**: Add "don't remind me again" option to install prompt +- **Auth**: Add `SESSION_SECRET_FILE` and `OAUTH_CLIENT_SECRET_FILE` environment variable support +- **Plugins**: Add `onAvatarResolve` plugin hook +- **Docker**: Publish main and dev branches as separate GHCR packages + +### Fixes + +- **Email**: Style links in plain text emails +- **Email**: Seed list history entry when app initializes on an email view +- **Email**: Remount composer on draft edit and preserve identity (#60) +- **Contacts**: Display contact names stored in `name.full` (#179) +- **Contacts**: Fix category dropdown blocking Save button in contact form (#177) +- **Contacts**: Resolve TS error from optional `name.components` in vCard parser +- **Search**: Search all folders when filtering emails by tag (#175) +- **Auth**: Include mount prefix in SSO redirect URI when app is served under a subpath +- **PWA**: Correct PWA icons with proper sizing, transparency, and dark/light mode support + ## 1.4.12 (2026-04-09) Thank you for your donations: diff --git a/README.md b/README.md index ac4b2966..f89a5989 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Built with Next.js and the JMAP protocol. [![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE) [![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT) -[![Version](https://img.shields.io/badge/version-1.4.12-green.svg?logo=git&logoColor=white)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.4.13-green.svg?logo=git&logoColor=white)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail) diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 9c29a7f5..b3561a74 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -10,6 +10,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useThemeStore } from "@/stores/theme-store"; import { useShallow } from "zustand/react/shallow"; import { useConfig } from "@/hooks/use-config"; +import { getPathPrefix } from "@/lib/browser-navigation"; import { cn } from "@/lib/utils"; import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; @@ -231,7 +232,8 @@ export default function LoginPage() { const startServerSideSso = useCallback(async () => { setOauthLoading(true); try { - const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; + const prefix = getPathPrefix(params.locale as string); + const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; const res = await fetch('/api/auth/sso/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 0e9a6ee3..2263d185 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -14,6 +14,7 @@ import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; import { useEmailStore } from "@/stores/email-store"; import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useSettingsStore } from "@/stores/settings-store"; +import { useContactStore } from "@/stores/contact-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useUIStore } from "@/stores/ui-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; @@ -61,6 +62,7 @@ export default function Home() { const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); const [composerDraftText, setComposerDraftText] = useState(""); const [pendingDraft, setPendingDraft] = useState(null); + const [composerSessionId, setComposerSessionId] = useState(0); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); @@ -79,6 +81,15 @@ export default function Home() { const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { identities } = useIdentityStore(); useIdentitySync(); + const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); + const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore(); + + // Load trusted senders address book when feature is enabled + useEffect(() => { + if (trustedSendersAddressBook && client && !trustedSendersLoaded) { + loadTrustedSendersBook(client); + } + }, [trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]); useEffect(() => { if (!isRateLimited || !rateLimitUntil) { @@ -642,6 +653,16 @@ export default function Home() { const htmlBody = draft.htmlBody?.[0]?.partId && draft.bodyValues?.[draft.htmlBody[0].partId] ? draft.bodyValues[draft.htmlBody[0].partId].value : undefined; + + // Try to find the identity that matches the draft's from address to preserve it + const draftFromEmail = draft.from?.[0]?.email; + const matchedIdentity = draftFromEmail + ? identities.find(id => id.email === draftFromEmail) + : null; + + // Increment session ID to force the composer to remount with fresh state, + // even if it was already open (e.g. right-clicking a draft while composing). + setComposerSessionId(id => id + 1); setPendingDraft({ to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '', cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '', @@ -650,7 +671,7 @@ export default function Home() { body: htmlBody || bodyText, showCc: (draft.cc?.length || 0) > 0, showBcc: (draft.bcc?.length || 0) > 0, - selectedIdentityId: null, + selectedIdentityId: matchedIdentity?.id ?? null, subAddressTag: '', mode: 'compose', draftId: draft.id, @@ -830,16 +851,22 @@ export default function Home() { const keywords = { ...email.keywords }; - // Remove old label and legacy color tags - set to false for JMAP to remove them - Object.keys(keywords).forEach(key => { - if (key.startsWith("$label:") || key.startsWith("$color:")) { - keywords[key] = false; + if (color === null) { + // Remove all label/color tags + Object.keys(keywords).forEach(key => { + if (key.startsWith("$label:") || key.startsWith("$color:")) { + keywords[key] = false; + } + }); + } else { + const jmapKey = `$label:${color}`; + if (keywords[jmapKey] === true) { + // Toggle off if already active + keywords[jmapKey] = false; + } else { + // Add the tag without disturbing others + keywords[jmapKey] = true; } - }); - - // Add new label tag if specified (using new $label: prefix) - if (color) { - keywords[`$label:${color}`] = true; } // Update email keywords via JMAP @@ -1656,8 +1683,9 @@ export default function Home() { }} > - - \ No newline at end of file + + + + + + + + + + + + + diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index bf1c89f2..a49f23c9 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -911,7 +911,6 @@ function CategoryComboBox({ }) { const [isOpen, setIsOpen] = useState(false); const [inputValue, setInputValue] = useState(""); - const wrapperRef = useRef(null); const inputRef = useRef(null); // Parse current keywords from comma-separated string @@ -946,17 +945,6 @@ function CategoryComboBox({ onChange(next); }, [currentKeywords, onChange]); - // Close dropdown on outside click - useEffect(() => { - if (!isOpen) return; - const handler = (e: MouseEvent) => { - if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { - setIsOpen(false); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [isOpen]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { @@ -970,7 +958,7 @@ function CategoryComboBox({ }; return ( -
+
{/* Keyword badges */} {currentKeywords.length > 0 && (
@@ -998,6 +986,7 @@ function CategoryComboBox({ value={inputValue} onChange={(e) => { setInputValue(e.target.value); setIsOpen(true); }} onFocus={() => setIsOpen(true)} + onBlur={() => setIsOpen(false)} onKeyDown={handleKeyDown} placeholder={currentKeywords.length === 0 ? placeholder : ""} /> @@ -1005,7 +994,7 @@ function CategoryComboBox({ {/* Dropdown */} {isOpen && (suggestions.length > 0 || canAddNew) && ( -
+
e.preventDefault()}> {suggestions.map(kw => (
{/* Mobile: send button in header */}
)} + {showAttachmentWarning && ( +
setShowAttachmentWarning(false)} + > +
e.stopPropagation()} + className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200" + > +
+

{t('forgot_attachment.title')}

+

+ {t('forgot_attachment.message', { keyword: attachmentWarningKeyword })} +

+
+
+ + +
+
+
+ )} + {showCloseDialog && (
{ } }; -// Get current label/color from email keywords (supports both $label: and legacy $color:) -const getCurrentColor = (keywords: Record | undefined) => { - if (!keywords) return null; +// Get all active label/color tag IDs from email keywords +const getCurrentColors = (keywords: Record | undefined): string[] => { + if (!keywords) return []; + const tags: string[] = []; for (const key of Object.keys(keywords)) { if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { - return key.startsWith("$label:") - ? key.slice("$label:".length) - : key.slice("$color:".length); + tags.push( + key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length) + ); } } - return null; + return tags; }; export function EmailContextMenu({ @@ -137,7 +138,7 @@ export function EmailContextMenu({ const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const isDraft = email.keywords?.['$draft'] === true; - const currentColor = getCurrentColor(email.keywords); + const currentColors = getCurrentColors(email.keywords); const showBatchActions = isMultiSelect && selectedCount > 1; const isInJunkFolder = currentMailboxRole === 'junk'; @@ -306,24 +307,27 @@ export function EmailContextMenu({ {/* Set tag submenu - only for single email */} {!showBatchActions && ( - {colorOptions.map((option) => ( - - ))} - {currentColor && ( + {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} + {currentColors.length > 0 && ( <> k.id === colorTagId) : null; + // Resolve color tags using keyword definitions from settings + const colorTagIds = getEmailColorTags(email.keywords); + const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords; + // Use first tag for background coloring + const keywordDef = keywordDefs[0] ?? null; const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; // Drag and drop functionality @@ -199,7 +201,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl )} {email.hasAttachment && } - {keywordDef && } + {keywordDefs.map((kd) => ( + + ))}
- {keywordDef && ( - ( + - - {keywordDef.label} + + {kd.label} - )} + ))} | undefined) => { - if (!keywords) return null; +const getCurrentColors = (keywords: Record | undefined): string[] => { + if (!keywords) return []; + const tags: string[] = []; for (const key of Object.keys(keywords)) { if ((key.startsWith("$label:") || key.startsWith("$color:")) && keywords[key] === true) { - return key.startsWith("$label:") - ? key.slice("$label:".length) - : key.slice("$color:".length); + tags.push( + key.startsWith("$label:") ? key.slice("$label:".length) : key.slice("$color:".length) + ); } } - return null; + return tags; }; // Helper function to format recipients with contextual display @@ -887,6 +888,9 @@ export function EmailViewer({ const attachmentPosition = useSettingsStore((state) => state.attachmentPosition); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); + const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); + const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender); + const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook); const emailKeywords = useSettingsStore((state) => state.emailKeywords); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); @@ -933,7 +937,8 @@ export function EmailViewer({ const moveMenuRef = useRef(null); const toolbarRef = useRef(null); const [hiddenPriorities, setHiddenPriorities] = useState>(new Set()); - const currentColor = getCurrentColor(email?.keywords); + const currentColors = getCurrentColors(email?.keywords); + const currentColor = currentColors[0] ?? null; // S/MIME state const [smimeStatus, setSmimeStatus] = useState(null); @@ -2309,9 +2314,11 @@ export function EmailViewer({ // Use shared sanitization config as base (more secure) const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG }; - // Check if sender is trusted + // Check if sender is trusted (localStorage list or address book) const senderEmail = email.from?.[0]?.email?.toLowerCase(); - const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false; + const senderIsTrusted = senderEmail + ? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail)) + : false; // Block external content based on policy: // 'allow' = never block, 'block' = always block (unless trusted), 'ask' = block until user allows or trusted @@ -2418,7 +2425,7 @@ export function EmailViewer({ html: '

No content available

', isHtml: false }; - }, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, cidBlobUrls]); + }, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, isTrustedAddressBookSender, trustedSendersAddressBook, cidBlobUrls]); // Override email content with S/MIME decrypted content when available const effectiveEmailContent = useMemo(() => { @@ -3055,43 +3062,51 @@ export function EmailViewer({ onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }} className={cn( "h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2", - currentColor && "bg-muted/50" + currentColors.length > 0 && "bg-muted/50" )} title={t('set_color')} > - {(() => { - const kw = currentColor ? emailKeywords.find(k => k.id === currentColor) : null; - const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; - return dotClass ? ( - <> - - {showToolbarLabels && {kw!.label}} - - ) : ( - <> - - {showToolbarLabels && {t('tag')}} - - ); - })()} + {currentColors.length > 0 ? ( + <> + + {currentColors.slice(0, 3).map((tagId) => { + const kw = emailKeywords.find(k => k.id === tagId); + return kw ? : null; + })} + + {showToolbarLabels && currentColors.length === 1 && ( + + {emailKeywords.find(k => k.id === currentColors[0])?.label} + + )} + + ) : ( + <> + + {showToolbarLabels && {t('tag')}} + + )} {tagMenuOpen && (
- {colorOptions.map((option) => ( - - ))} - {currentColor && ( + {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} + {currentColors.length > 0 && ( <>
- ))} - {currentColor && ( + {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} + {currentColors.length > 0 && ( <>
- ))} - {currentColor && ( + {colorOptions.map((option) => { + const isActive = currentColors.includes(option.value); + return ( + + ); + })} + {currentColors.length > 0 && ( )} - {/* Color tag dot */} - {currentColor && (() => { - const kw = emailKeywords.find(k => k.id === currentColor); - const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; - return dotClass ? ( - - ) : null; - })()} + {/* Color tag dots */} + {currentColors.length > 0 && ( + + {currentColors.map((tagId) => { + const kw = emailKeywords.find(k => k.id === tagId); + const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; + return dotClass ? ( + + ) : null; + })} + + )} {isImportant && ( {t('important')} @@ -4545,7 +4570,11 @@ export function EmailViewer({ onClick={() => { const senderEmail = email.from?.[0]?.email; if (senderEmail) { - addTrustedSender(senderEmail); + if (trustedSendersAddressBook && client) { + addToTrustedSendersBook(client, senderEmail).catch(console.error); + } else { + addTrustedSender(senderEmail); + } setAllowExternalContent(true); } }} diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 3c746df4..86fa0ce2 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -31,6 +31,7 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; import { useSettingsStore } from "@/stores/settings-store"; +import { useContactStore } from "@/stores/contact-store"; import { useAuthStore } from "@/stores/auth-store"; import { isFilePreviewable } from "@/lib/file-preview"; @@ -84,6 +85,10 @@ export function ThreadConversationView({ const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); + const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); + const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender); + const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook); + const { client } = useAuthStore(); // Track which emails are expanded (most recent by default) const [expandedIds, setExpandedIds] = useState>(new Set()); @@ -164,7 +169,9 @@ export function ThreadConversationView({
{emails.map((email, index) => { const senderEmail = email.from?.[0]?.email?.toLowerCase(); - const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) : false; + const senderIsTrusted = senderEmail + ? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail)) + : false; return ( toggleExpanded(email.id)} onAllowExternal={() => toggleAllowExternal(email.id)} onTrustSender={senderEmail ? () => { - addTrustedSender(senderEmail); + if (trustedSendersAddressBook && client) { + addToTrustedSendersBook(client, senderEmail).catch(console.error); + } else { + addTrustedSender(senderEmail); + } toggleAllowExternal(email.id); } : undefined} onReply={onReply ? () => onReply(email) : undefined} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index f0153957..9ee8e705 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -9,7 +9,7 @@ import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSqu import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; -import { getThreadColorTag, getEmailColorTag } from "@/lib/thread-utils"; +import { getThreadColorTag, getEmailColorTags } from "@/lib/thread-utils"; import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { ThreadEmailItem } from "./thread-email-item"; @@ -67,9 +67,10 @@ const SingleEmailItem = React.forwardRef( const isFocusedMailLayout = mailLayout === 'focus'; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; - // Resolve color and keyword definition from keyword definitions if not passed directly - const tagId = getEmailColorTag(email.keywords); - const resolvedKeywordDef = tagId ? emailKeywords.find(k => k.id === tagId) : null; + // Resolve color tags using keyword definitions + const tagIds = getEmailColorTags(email.keywords); + const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords; + const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null; const resolvedColorTag = (() => { if (colorTag) return colorTag; return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null; @@ -212,7 +213,9 @@ const SingleEmailItem = React.forwardRef( )} {email.hasAttachment && } - {resolvedKeywordDef && } + {resolvedKeywordDefs.map((kd) => ( + + ))} (
- {resolvedKeywordDef && ( - ( + - - {resolvedKeywordDef.label} + + {kd.label} - )} + ))} )} {hasAttachment && } - {keywordDef && } + {keywordDef && ( + + )} { - const count = trustedSenders.length; + const count = trustedSendersAddressBook ? trustedSenderEmails.length : trustedSenders.length; if (count === 0) return t('trusted_senders.count_zero'); if (count === 1) return t('trusted_senders.count_one'); return t('trusted_senders.count_other', { count }); @@ -337,6 +344,63 @@ export function EmailSettings() { /> + {/* Attachment Reminder */} + + updateSetting('attachmentReminderEnabled', checked)} + /> + + {attachmentReminderEnabled && ( +
+
+ +

{t('attachment_reminder.keywords_description')}

+
+
+ {attachmentReminderKeywords.map((kw) => ( + + {kw} + + + ))} +
+
{ + e.preventDefault(); + const trimmed = newKeyword.trim().toLowerCase(); + if (trimmed && !attachmentReminderKeywords.includes(trimmed)) { + updateSetting('attachmentReminderKeywords', [...attachmentReminderKeywords, trimmed]); + } + setNewKeyword(''); + }} + > + setNewKeyword(e.target.value)} + placeholder={t('attachment_reminder.add_placeholder')} + className="flex-1 min-w-0 px-2 py-1 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring" + /> + +
+
+ )} + {/* Quick Hover Actions */} {isFeatureEnabled('hoverActionsConfigEnabled') && (
@@ -511,6 +575,14 @@ export function EmailSettings() { + {/* Trusted Senders — address book storage */} + + updateSetting('trustedSendersAddressBook', checked)} + /> + + {/* Trusted Senders Modal */} void; onDelete: () => void; + onDragStart: () => void; + onDragOver: (e: React.DragEvent) => void; + onDrop: () => void; + onDragEnd: () => void; + isDragOver: boolean; + isDragging: boolean; }) { const t = useTranslations("settings.keywords"); const palette = KEYWORD_PALETTE[keyword.color]; return ( -
+
{keyword.label} @@ -166,9 +189,39 @@ export function KeywordSettings() { const [editingId, setEditingId] = useState(null); const [isAdding, setIsAdding] = useState(false); const [isMigrating, setIsMigrating] = useState(false); + const [dragIndex, setDragIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); const existingIds = emailKeywords.map((k) => k.id); + const handleDragStart = (index: number) => { + setDragIndex(index); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + if (index !== dragOverIndex) setDragOverIndex(index); + }; + + const handleDrop = (index: number) => { + if (dragIndex === null || dragIndex === index) { + setDragIndex(null); + setDragOverIndex(null); + return; + } + const reordered = [...emailKeywords]; + const [moved] = reordered.splice(dragIndex, 1); + reordered.splice(index, 0, moved); + reorderKeywords(reordered); + setDragIndex(null); + setDragOverIndex(null); + }; + + const handleDragEnd = () => { + setDragIndex(null); + setDragOverIndex(null); + }; + const handleAdd = (keyword: KeywordDefinition) => { addKeyword(keyword); setIsAdding(false); @@ -220,7 +273,7 @@ export function KeywordSettings() { {t("migrating")}
)} - {emailKeywords.map((keyword) => + {emailKeywords.map((keyword, index) => editingId === keyword.id ? ( handleDelete(keyword.id)} + onDragStart={() => handleDragStart(index)} + onDragOver={(e) => handleDragOver(e, index)} + onDrop={() => handleDrop(index)} + onDragEnd={handleDragEnd} + isDragOver={dragOverIndex === index && dragIndex !== index} + isDragging={dragIndex === index} /> ) )} diff --git a/components/trusted-senders-modal.tsx b/components/trusted-senders-modal.tsx index 8fd8cb91..d2438409 100644 --- a/components/trusted-senders-modal.tsx +++ b/components/trusted-senders-modal.tsx @@ -2,9 +2,11 @@ import { useState, useEffect, useRef, useMemo } from "react"; import { useTranslations } from "next-intl"; -import { X, ShieldCheck, Search, Trash2, Plus } from "lucide-react"; +import { X, ShieldCheck, Search, Trash2, Plus, Loader2 } from "lucide-react"; import { Avatar } from "@/components/ui/avatar"; import { useSettingsStore } from "@/stores/settings-store"; +import { useContactStore } from "@/stores/contact-store"; +import { useAuthStore } from "@/stores/auth-store"; import { cn } from "@/lib/utils"; interface TrustedSendersModalProps { @@ -17,22 +19,43 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp const modalRef = useRef(null); const inputRef = useRef(null); - const { trustedSenders, addTrustedSender, removeTrustedSender } = useSettingsStore(); + const { trustedSenders, addTrustedSender, removeTrustedSender, trustedSendersAddressBook } = useSettingsStore(); + const { + trustedSenderEmails, + trustedSendersLoaded, + trustedSendersLoading, + loadTrustedSendersBook, + addToTrustedSendersBook, + removeFromTrustedSendersBook, + } = useContactStore(); + const { client } = useAuthStore(); const [searchQuery, setSearchQuery] = useState(""); const [isAdding, setIsAdding] = useState(false); const [newEmail, setNewEmail] = useState(""); const [emailError, setEmailError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + // When address book mode is on, load the book on first open + useEffect(() => { + if (isOpen && trustedSendersAddressBook && client && !trustedSendersLoaded) { + loadTrustedSendersBook(client); + } + }, [isOpen, trustedSendersAddressBook, client, trustedSendersLoaded, loadTrustedSendersBook]); + + // The active list depends on mode + const activeSenders = trustedSendersAddressBook ? trustedSenderEmails : trustedSenders; + const isLoading = trustedSendersAddressBook && (!trustedSendersLoaded || trustedSendersLoading); // Filter senders based on search query const filteredSenders = useMemo(() => { - if (!searchQuery.trim()) return trustedSenders; + if (!searchQuery.trim()) return activeSenders; const query = searchQuery.toLowerCase(); - return trustedSenders.filter((email) => email.toLowerCase().includes(query)); - }, [trustedSenders, searchQuery]); + return activeSenders.filter((email) => email.toLowerCase().includes(query)); + }, [activeSenders, searchQuery]); // Show search only when 5+ senders - const showSearch = trustedSenders.length >= 5; + const showSearch = activeSenders.length >= 5; // Close on Escape key useEffect(() => { @@ -90,7 +113,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp return emailRegex.test(email); }; - const handleAddSender = () => { + const handleAddSender = async () => { const trimmedEmail = newEmail.trim().toLowerCase(); if (!trimmedEmail) { @@ -103,15 +126,34 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp return; } - if (trustedSenders.includes(trimmedEmail)) { + if (activeSenders.includes(trimmedEmail)) { setEmailError(t("already_added")); return; } - addTrustedSender(trimmedEmail); - setNewEmail(""); - setIsAdding(false); - setEmailError(""); + setIsSubmitting(true); + try { + if (trustedSendersAddressBook && client) { + await addToTrustedSendersBook(client, trimmedEmail); + } else { + addTrustedSender(trimmedEmail); + } + setNewEmail(""); + setIsAdding(false); + setEmailError(""); + } catch { + setEmailError(t("save_error")); + } finally { + setIsSubmitting(false); + } + }; + + const handleRemoveSender = async (email: string) => { + if (trustedSendersAddressBook && client) { + await removeFromTrustedSendersBook(client, email); + } else { + removeTrustedSender(email); + } }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -170,7 +212,11 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp {/* Content */}
- {trustedSenders.length === 0 ? ( + {isLoading ? ( +
+ +
+ ) : activeSenders.length === 0 ? ( /* Empty State */
@@ -209,7 +255,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp {email}
{/* Footer - Add sender */} - {trustedSenders.length > 0 && ( + {!isLoading && activeSenders.length > 0 && (
{isAdding ? (
@@ -244,9 +290,10 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp />
{emailError && ( diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx index cdc69d18..61961d5f 100644 --- a/components/ui/avatar.tsx +++ b/components/ui/avatar.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useEffect } from "react"; import { cn } from "@/lib/utils"; import { useSettingsStore } from "@/stores/settings-store"; import { useContactStore, getContactPhotoUri } from "@/stores/contact-store"; import { useConfig } from "@/hooks/use-config"; +import { avatarHooks } from "@/lib/plugin-hooks"; const IS_DEV = process.env.NODE_ENV !== "production"; @@ -143,10 +144,26 @@ interface AvatarProps { export function Avatar({ name, email, contactPhotoUri, size = "md", className }: AvatarProps) { const [imgError, setImgError] = useState(false); + const [pluginAvatarUrl, setPluginAvatarUrl] = useState(null); + const [pluginAvatarFailed, setPluginAvatarFailed] = useState(false); const senderFavicons = useSettingsStore((s) => s.senderFavicons); const contacts = useContactStore((s) => s.contacts); const { devMode } = useConfig(); + // Ask plugins (e.g. Gravatar) to resolve an avatar URL for this email address. + // Runs whenever email or name changes; resets plugin avatar state on each change. + useEffect(() => { + setPluginAvatarUrl(null); + setPluginAvatarFailed(false); + if (!email || avatarHooks.onAvatarResolve.size === 0) return; + let cancelled = false; + avatarHooks.onAvatarResolve + .transform(null as string | null, { email, name }) + .then((url) => { if (!cancelled) setPluginAvatarUrl(url); }) + .catch(() => { if (!cancelled) setPluginAvatarFailed(true); }); + return () => { cancelled = true; }; + }, [email, name]); + // Look up contact photo by email from the contact store const resolvedContactPhoto = useMemo(() => { if (contactPhotoUri) return contactPhotoUri; @@ -202,19 +219,25 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className }: const showFavicon = senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed; - // Priority: contact photo > custom avatar > profile picture > company favicon > initials + // Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null; + const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl; const imgSrc = !imgError && !domainFailed - ? resolvedContactPhoto || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null) - : (resolvedContactPhoto || customAvatar || profilePic || null); + ? resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null) + : (resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null); const handleImgError = useCallback(() => { + // If the plugin avatar just failed, mark it and fall through to the next source + if (pluginAvatar && imgSrc === pluginAvatar) { + setPluginAvatarFailed(true); + return; + } setImgError(true); - // If this was a favicon URL (not a contact photo, custom avatar or profile pic), remember the domain - if (faviconDomain && !resolvedContactPhoto && !customAvatar && !profilePic) { + // If this was a favicon URL (not a contact photo, plugin avatar, custom avatar or profile pic), remember the domain + if (faviconDomain && !resolvedContactPhoto && !pluginAvatar && !customAvatar && !profilePic) { failedFaviconDomains.add(faviconDomain); } - }, [faviconDomain, resolvedContactPhoto, customAvatar, profilePic]); + }, [imgSrc, pluginAvatar, faviconDomain, resolvedContactPhoto, customAvatar, profilePic]); return (
; + 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-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/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 cddd4bea..5f5c9771 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2818,6 +2818,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/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 99a09683..b2133cf4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.4.12", + "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 index f5a709ad..1129a436 100644 Binary files a/public/icon-192x192.png and b/public/icon-192x192.png differ diff --git a/public/icon-512x512.png b/public/icon-512x512.png index 3d566082..8d21a585 100644 Binary files a/public/icon-512x512.png 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/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 42630eac..ca3173b1 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 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,