From 3e85e073635015df521b406c281cb55fa97e3c1d Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Fri, 10 Apr 2026 14:46:04 +0200 Subject: [PATCH 01/17] feat: separate branch containers into beta and dev packages --- .github/workflows/docker-publish.yml | 30 +++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 1af541b6..43114ea9 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -21,11 +21,23 @@ on: - ".github/workflows/docker-publish.yml" workflow_dispatch: -env: - IMAGE_NAME: ghcr.io/${{ github.repository }}-${{ github.ref_name }} - jobs: + prepare: + runs-on: ubuntu-latest + outputs: + image_name: ${{ steps.set.outputs.image_name }} + steps: + - name: Set image name + id: set + run: | + if [ "${{ github.ref_name }}" = "main" ]; then + echo "image_name=ghcr.io/${{ github.repository }}-beta" >> $GITHUB_OUTPUT + else + echo "image_name=ghcr.io/${{ github.repository }}-${{ github.ref_name }}" >> $GITHUB_OUTPUT + fi + build: + needs: prepare strategy: fail-fast: false matrix: @@ -57,7 +69,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.IMAGE_NAME }} + images: ${{ needs.prepare.outputs.image_name }} - name: Build and push by digest id: build @@ -66,7 +78,7 @@ jobs: context: . platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + outputs: type=image,name=${{ needs.prepare.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }} @@ -86,7 +98,7 @@ jobs: merge: runs-on: ubuntu-latest - needs: build + needs: [prepare, build] permissions: contents: read packages: write @@ -113,7 +125,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.IMAGE_NAME }} + images: ${{ needs.prepare.outputs.image_name }} tags: | type=raw,value=latest type=sha @@ -122,8 +134,8 @@ jobs: working-directory: /tmp/digests run: | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ - $(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *) + $(printf '${{ needs.prepare.outputs.image_name }}@sha256:%s ' *) - name: Inspect image run: | - docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} + docker buildx imagetools inspect ${{ needs.prepare.outputs.image_name }}:${{ steps.meta.outputs.version }} From 4f54f768e83bb2b4efd80d6c92c0a8af9f45b6ea Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Fri, 10 Apr 2026 17:31:13 +0200 Subject: [PATCH 02/17] Feat: enable keyword reordering #174 and multi-tag support per email #173 --- app/[locale]/page.tsx | 24 ++-- components/email/email-context-menu.tsx | 56 ++++---- components/email/email-list-item.tsx | 26 ++-- components/email/email-viewer.tsx | 172 +++++++++++++---------- components/email/thread-list-item.tsx | 29 ++-- components/settings/keyword-settings.tsx | 65 ++++++++- hooks/use-tag-drop.ts | 9 +- lib/thread-utils.ts | 27 ++-- 8 files changed, 255 insertions(+), 153 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 0e9a6ee3..7f57ae21 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -830,16 +830,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 diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 1e5e93d2..983fd526 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -89,17 +89,18 @@ const getMailboxIcon = (role?: string) => { } }; -// 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 @@ -933,7 +934,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); @@ -3055,43 +3057,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')} 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 && ( + + )} 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/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/thread-utils.ts b/lib/thread-utils.ts index 68b53f10..65dfbae0 100644 --- a/lib/thread-utils.ts +++ b/lib/thread-utils.ts @@ -152,21 +152,32 @@ export const KEYWORD_PREFIX = "$label:"; export const KEYWORD_PREFIX_LEGACY = "$color:"; /** - * Gets label/color tag from email keywords (if any). + * Gets all active label/color tag IDs from email keywords. * Reads both the current $label: prefix and the legacy $color: prefix. */ -export function getEmailColorTag(keywords: 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; } /** From 5f150f039d1b6711d130149895a397829aba341c Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Fri, 10 Apr 2026 18:24:52 +0200 Subject: [PATCH 03/17] feat: warn on send when attachment keyword found but no file attached #172 --- components/email/email-composer.tsx | 61 +++++++++++++++++++++++-- components/settings/email-settings.tsx | 63 +++++++++++++++++++++++++- locales/de/common.json | 17 ++++++- locales/en/common.json | 17 ++++++- locales/es/common.json | 17 ++++++- locales/fr/common.json | 17 ++++++- locales/it/common.json | 17 ++++++- locales/ja/common.json | 17 ++++++- locales/ko/common.json | 17 ++++++- locales/lv/common.json | 17 ++++++- locales/nl/common.json | 17 ++++++- locales/pl/common.json | 17 ++++++- locales/pt/common.json | 17 ++++++- locales/ru/common.json | 17 ++++++- locales/zh/common.json | 17 ++++++- stores/settings-store.ts | 37 +++++++++++++++ 16 files changed, 365 insertions(+), 17 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 06a49912..ecbc1c87 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -103,6 +103,8 @@ export function EmailComposer({ const timeFormat = useSettingsStore((state) => state.timeFormat); const plainTextMode = useSettingsStore((state) => state.plainTextMode); const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity); + const attachmentReminderEnabled = useSettingsStore((state) => state.attachmentReminderEnabled); + const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords); // Initialize with reply/forward data if provided const getInitialTo = () => { @@ -212,6 +214,8 @@ export function EmailComposer({ const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null); const [smimePassphraseInput, setSmimePassphraseInput] = useState(''); const [smimePassphraseError, setSmimePassphraseError] = useState(''); + const [showAttachmentWarning, setShowAttachmentWarning] = useState(false); + const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState(''); const saveTemplateModalRef = useFocusTrap({ isActive: showSaveAsTemplate, @@ -225,6 +229,12 @@ export function EmailComposer({ restoreFocus: true, }); + const attachmentWarningRef = useFocusTrap({ + isActive: showAttachmentWarning, + onEscape: () => setShowAttachmentWarning(false), + restoreFocus: true, + }); + const { client } = useAuthStore(); const identities = useIdentityStore((s) => s.identities); const primaryIdentity = identities[0] ?? null; @@ -723,7 +733,7 @@ export function EmailComposer({ return undefined; }; - const handleSend = async () => { + const handleSend = async (skipAttachmentCheck = false) => { const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); const bccAddresses = bcc.split(",").map(e => e.trim()).filter(Boolean); @@ -742,6 +752,21 @@ export function EmailComposer({ return; } + // Attachment reminder check + if (!skipAttachmentCheck && attachmentReminderEnabled) { + const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error); + if (!hasAttachments) { + const bodyText = htmlToPlainText(body); + const searchText = `${subject} ${bodyText}`.toLowerCase(); + const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase())); + if (matched) { + setAttachmentWarningKeyword(matched); + setShowAttachmentWarning(true); + return; + } + } + } + let finalDraftId = draftId; if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); @@ -1002,7 +1027,7 @@ export function EmailComposer({
{/* 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 && (
+ {/* 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') && (
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..8062a10e 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", @@ -914,6 +920,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": { 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/stores/settings-store.ts b/stores/settings-store.ts index e500a0a0..870d9fab 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -185,6 +185,10 @@ interface SettingsState { // Keywords (labels/tags) emailKeywords: KeywordDefinition[]; + // Attachment Reminder + attachmentReminderEnabled: boolean; + attachmentReminderKeywords: string[]; + // Sidebar Apps sidebarApps: SidebarApp[]; keepAppsLoaded: boolean; @@ -314,6 +318,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 +447,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, From 5ddb2acfc7407b07c582a0c327c6620c3c48cd3c Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Fri, 10 Apr 2026 18:40:32 +0200 Subject: [PATCH 04/17] fix: category dropdown blocking Save button in contact form #177 --- components/contacts/contact-form.tsx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) 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 => ( + {/* Trusted Senders — address book storage */} + + updateSetting('trustedSendersAddressBook', checked)} + /> + + {/* Trusted Senders Modal */} (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/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/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/locales/en/common.json b/locales/en/common.json index 8062a10e..8a8a6549 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -893,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", @@ -1197,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/stores/contact-store.ts b/stores/contact-store.ts index dd844513..ee80767d 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -3,6 +3,7 @@ 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) { @@ -44,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[]; @@ -53,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'; @@ -95,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()( @@ -139,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, @@ -667,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/settings-store.ts b/stores/settings-store.ts index 870d9fab..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; @@ -273,6 +275,7 @@ const DEFAULT_SETTINGS = { // Privacy & Security sessionTimeout: 0, // Never trustedSenders: [] as string[], + trustedSendersAddressBook: false, // Filters expandedFilterView: false, From 4531cfe47cd39872509e0be4912c8d1f5681f1a4 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 01:14:25 +0200 Subject: [PATCH 12/17] fix: resolve TS errors from missing createAddressBook in demo client and client ref in thread view --- components/email/thread-conversation-view.tsx | 1 + lib/demo/demo-client.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 27908391..86fa0ce2 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -88,6 +88,7 @@ export function ThreadConversationView({ 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()); 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); From 5cfc905f100c689f70d981ad784273714050b93b Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 01:33:28 +0200 Subject: [PATCH 13/17] fix: seed list history entry when app initializes on an email view --- hooks/use-browser-navigation.ts | 35 ++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/hooks/use-browser-navigation.ts b/hooks/use-browser-navigation.ts index 76c5f680..f4676f6f 100644 --- a/hooks/use-browser-navigation.ts +++ b/hooks/use-browser-navigation.ts @@ -147,9 +147,38 @@ export function useBrowserNavigation({ if (!initializedRef.current) { initializedRef.current = true; - // 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, ""); + + if (emailId || threadId) { + // The app is initializing directly on an email/thread view (e.g. the + // user navigated here from /settings or an external link). Seed a + // "list" history entry first so that the toolbar back button returns + // to the list instead of leaving the app entirely. + const listSnapshot: NavSnapshot = { + mailboxId, + emailId: null, + threadId: null, + composerOpen: false, + sidebarOpen, + }; + const listStored: StoredNavState = { + ...listSnapshot, + navId: ++navIdCounter, + }; + const baseState = (window.history.state ?? {}) as Record< + string, + unknown + >; + 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, ""); } From d24f402b0c350d29e5f27326ed07766657019ec9 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 01:38:53 +0200 Subject: [PATCH 14/17] fix: style links in plain text emails --- app/globals.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/globals.css b/app/globals.css index 9dae64fa..65e1919e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -208,6 +208,11 @@ body { padding: 1rem 1.25rem; } +.email-content-text a { + color: var(--color-primary); + text-decoration: underline; +} + .email-content { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", From a201c1617b23241d6df32c87adbdc6a68095cc1b Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 01:59:53 +0200 Subject: [PATCH 15/17] feat: add onAvatarResolve plugin hook --- components/ui/avatar.tsx | 37 ++++++++++++++++++++++++++++++------- lib/plugin-api.ts | 8 +++++++- lib/plugin-hooks.ts | 8 ++++++++ 3 files changed, 45 insertions(+), 8 deletions(-) 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 (
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 Date: Sun, 12 Apr 2026 02:13:55 +0200 Subject: [PATCH 16/17] chore: update version to 1.4.13 --- CHANGELOG.md | 24 ++++++++++++++++++++++++ README.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a261a35..b2549085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 1.4.13 (2026-04-12) + +### 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/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", From 44e0e172038ebb6f93097da99be617ce00bd960d Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 02:17:54 +0200 Subject: [PATCH 17/17] chore: update version to 1.4.13 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2549085..169df843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## 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)