From bb40326809fbbb6bb082b890bbcf3ac7502e8f57 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 12 Mar 2026 03:10:40 +0100 Subject: [PATCH] feat: implement tagging functionality for emails with drag-and-drop support --- app/[locale]/page.tsx | 23 +++++ app/api/dev-jmap/[...path]/route.ts | 98 ++++++++++++++---- components/layout/sidebar.tsx | 152 +++++++++++++++++++++++++++- hooks/use-tag-drop.ts | 115 +++++++++++++++++++++ lib/jmap/client.ts | 7 +- locales/de/common.json | 4 + locales/en/common.json | 4 + locales/es/common.json | 4 + locales/fr/common.json | 4 + locales/it/common.json | 4 + locales/ja/common.json | 4 + locales/nl/common.json | 4 + locales/pt/common.json | 4 + stores/email-store.ts | 25 ++++- 14 files changed, 426 insertions(+), 26 deletions(-) create mode 100644 hooks/use-tag-drop.ts diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 535db389..5d85aca0 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -105,6 +105,8 @@ export default function Home() { clearSearchFilters, toggleAdvancedSearch, advancedSearch, + selectedKeyword, + selectKeyword, } = useEmailStore(); // Keyboard shortcuts handlers @@ -594,6 +596,25 @@ export default function Home() { } }; + const handleTagSelect = async (keywordId: string | null) => { + selectKeyword(keywordId); + + // On mobile, close sidebar and go to list view + if (isMobile) { + setSidebarOpen(false); + setActiveView("list"); + } + + // On tablet, show the list again + if (isTablet) { + setTabletListVisible(true); + } + + if (client) { + await fetchEmails(client); + } + }; + const handleLogout = () => { logout(); router.push('/login'); @@ -852,7 +873,9 @@ export default function Home() { { setComposerMode('compose'); setShowComposer(true); diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 4fa5ba12..f8398fc3 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -119,7 +119,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true }, size: 5100, receivedAt: daysAgo(1), + id: 'email-002', threadId: 'thread-002', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 5100, receivedAt: daysAgo(1), from: [{ name: 'Pierre Dubois', email: 'pierre@dubois.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [{ name: 'Karel de Vries', email: 'karel@devries.example' }], @@ -151,7 +151,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 6200, receivedAt: daysAgo(0), + id: 'email-004', threadId: 'thread-004', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:red': true }, size: 6200, receivedAt: daysAgo(0), from: [{ name: 'GitHub Notifications', email: 'notifications@github.com' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: '[jmap-webmail] New issue: Add dark mode toggle (#42)', @@ -180,7 +180,7 @@ const emails: MockEmail[] = [ }, // Newsletter with full HTML { - id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 18200, receivedAt: daysAgo(0), + id: 'email-013', threadId: 'thread-012', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:purple': true }, size: 18200, receivedAt: daysAgo(0), from: [{ name: 'Launchpad Weekly', email: 'hello@launchpad.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Launchpad Weekly #47 — The future of the open web', @@ -227,7 +227,7 @@ const emails: MockEmail[] = [ ], }, { - id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: {}, size: 4100, receivedAt: hoursAgo(3), + id: 'email-016', threadId: 'thread-015', mailboxIds: { 'mb-inbox': true }, keywords: { '$label:green': true }, size: 4100, receivedAt: hoursAgo(3), from: [{ name: 'Élise Moreau', email: 'elise.moreau@fjord-systems.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Code review request: JMAP-342 contact import', @@ -254,7 +254,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true }, size: 4700, receivedAt: daysAgo(1), + id: 'email-018', threadId: 'thread-017', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:orange': true }, size: 4700, receivedAt: daysAgo(1), from: [{ name: 'Hetzner Cloud', email: 'billing@hetzner.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Your Hetzner invoice is available — February 2026', @@ -354,7 +354,7 @@ const emails: MockEmail[] = [ }, }, { - id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true }, size: 4100, receivedAt: daysAgo(6), + id: 'email-025', threadId: 'thread-024', mailboxIds: { 'mb-inbox': true }, keywords: { $seen: true, $flagged: true, '$label:blue': true }, size: 4100, receivedAt: daysAgo(6), from: [{ name: 'Stripe Developer', email: 'developer-updates@stripe.example' }], to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], subject: 'Action required: API v2023-10 deprecation on April 15, 2026', @@ -1302,22 +1302,75 @@ function handleMailboxSet(args: MethodArgs, callId: string): MethodResult { } function handleEmailQuery(args: MethodArgs, callId: string): MethodResult { - const filter = args.filter as Record | undefined; + const filter = args.filter as Record | undefined; const limit = (args.limit as number) || 50; const position = (args.position as number) || 0; let filtered = [...emails]; - if (filter?.inMailbox) { - filtered = filtered.filter((e) => e.mailboxIds[filter.inMailbox]); - } - if (filter?.text) { - const q = (filter.text as string).toLowerCase(); - filtered = filtered.filter( - (e) => - (e.subject?.toLowerCase().includes(q)) || - (e.preview?.toLowerCase().includes(q)) || - e.from?.some((f) => f.name?.toLowerCase().includes(q) || f.email.toLowerCase().includes(q)), - ); + + // Support both flat filters and operator/conditions compound filters + const applyFilter = (f: Record, list: MockEmail[]): MockEmail[] => { + let result = list; + if (f.operator && Array.isArray(f.conditions)) { + const sub = (f.conditions as Record[]).map(c => applyFilter(c, result)); + if (f.operator === 'AND') { + result = sub.reduce((acc, s) => acc.filter(e => s.includes(e))); + } else if (f.operator === 'OR') { + const ids = new Set(sub.flat().map(e => e.id)); + result = result.filter(e => ids.has(e.id)); + } + return result; + } + if (f.inMailbox) { + result = result.filter((e) => e.mailboxIds[f.inMailbox as string]); + } + if (f.text) { + const q = (f.text as string).toLowerCase(); + result = result.filter( + (e) => + (e.subject?.toLowerCase().includes(q)) || + (e.preview?.toLowerCase().includes(q)) || + e.from?.some((addr) => addr.name?.toLowerCase().includes(q) || addr.email.toLowerCase().includes(q)), + ); + } + if (f.hasKeyword) { + const kw = f.hasKeyword as string; + result = result.filter((e) => e.keywords[kw] === true); + } + if (f.notKeyword) { + const kw = f.notKeyword as string; + result = result.filter((e) => !e.keywords[kw]); + } + if (f.from) { + const q = (f.from as string).toLowerCase(); + result = result.filter((e) => e.from?.some((addr) => addr.name?.toLowerCase().includes(q) || addr.email.toLowerCase().includes(q))); + } + if (f.to) { + const q = (f.to as string).toLowerCase(); + result = result.filter((e) => e.to?.some((addr) => addr.name?.toLowerCase().includes(q) || addr.email.toLowerCase().includes(q))); + } + if (f.subject) { + const q = (f.subject as string).toLowerCase(); + result = result.filter((e) => e.subject?.toLowerCase().includes(q)); + } + if (f.hasAttachment === true) { + result = result.filter((e) => e.hasAttachment); + } else if (f.hasAttachment === false) { + result = result.filter((e) => !e.hasAttachment); + } + if (f.after) { + const after = new Date(f.after as string).getTime(); + result = result.filter((e) => new Date(e.receivedAt).getTime() >= after); + } + if (f.before) { + const before = new Date(f.before as string).getTime(); + result = result.filter((e) => new Date(e.receivedAt).getTime() <= before); + } + return result; + }; + + if (filter) { + filtered = applyFilter(filter, filtered); } // Sort newest first @@ -1377,9 +1430,14 @@ function handleEmailSet(args: MethodArgs, callId: string): MethodResult { email.mailboxIds = changes.mailboxIds as Record; } - // Full keywords replacement + // Full keywords replacement (strip false values per JMAP spec: keywords is a set) if (changes.keywords !== undefined) { - email.keywords = changes.keywords as Record; + const raw = changes.keywords as Record; + const cleaned: Record = {}; + for (const [k, v] of Object.entries(raw)) { + if (v) cleaned[k] = true; + } + email.keywords = cleaned; } // Patch-style keyword updates: "keywords/$seen", "keywords/$flagged", etc. diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 91678bad..86576200 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -23,21 +23,26 @@ import { Palmtree, Settings, X, + Tag, } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { Mailbox } from "@/lib/jmap/types"; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useMailboxDrop } from "@/hooks/use-mailbox-drop"; +import { useTagDrop } from "@/hooks/use-tag-drop"; import { useUIStore } from "@/stores/ui-store"; import { useAuthStore } from "@/stores/auth-store"; import { useVacationStore } from "@/stores/vacation-store"; +import { useSettingsStore, KEYWORD_PALETTE, KeywordDefinition } from "@/stores/settings-store"; import { toast } from "@/stores/toast-store"; import { debug } from "@/lib/debug"; interface SidebarProps { mailboxes: Mailbox[]; selectedMailbox?: string; + selectedKeyword?: string | null; onMailboxSelect?: (mailboxId: string) => void; + onTagSelect?: (keywordId: string | null) => void; onCompose?: () => void; onSidebarClose?: () => void; className?: string; @@ -217,6 +222,62 @@ function MailboxTreeItem({ ); } +function TagItem({ + kw, + isSelected, + isCollapsed, + onTagSelect, +}: { + kw: KeywordDefinition; + isSelected: boolean; + isCollapsed: boolean; + onTagSelect?: (keywordId: string | null) => void; +}) { + const t = useTranslations('notifications'); + const palette = KEYWORD_PALETTE[kw.color]; + const { isDragging: globalDragging } = useDragDropContext(); + const { dropHandlers, isValidDropTarget } = useTagDrop({ + tagId: kw.id, + onSuccess: (count, _tagLabel) => { + if (count === 1) { + toast.success(t('email_tagged'), kw.label); + } else { + toast.success(t('emails_tagged', { count }), kw.label); + } + }, + onError: () => { + toast.error(t('tag_failed'), kw.label); + }, + }); + + return ( +
+ +
+ ); +} + function VacationBanner() { const t = useTranslations('sidebar'); const router = useRouter(); @@ -243,7 +304,9 @@ function VacationBanner() { export function Sidebar({ mailboxes = [], selectedMailbox = "", + selectedKeyword = null, onMailboxSelect, + onTagSelect, onCompose, onSidebarClose, className, @@ -251,6 +314,13 @@ export function Sidebar({ const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore(); const { primaryIdentity } = useAuthStore(); const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [tagsExpanded, setTagsExpanded] = useState(() => { + try { + const stored = localStorage.getItem('sidebarTagsExpanded'); + return stored !== null ? JSON.parse(stored) : true; + } catch { return true; } + }); + const emailKeywords = useSettingsStore(s => s.emailKeywords); const t = useTranslations('sidebar'); useEffect(() => { @@ -379,7 +449,7 @@ export function Sidebar({ )} + + {/* Tags Section */} + {emailKeywords.length > 0 && ( + <> +
+ {!isCollapsed && ( + + )} + + +
+ + {((tagsExpanded && !isCollapsed) || isCollapsed) && ( +
+ {emailKeywords.map((kw) => { + const isSelected = selectedKeyword === kw.id; + return ( + + ); + })} +
+ )} + + )} {/* Compose Button */} diff --git a/hooks/use-tag-drop.ts b/hooks/use-tag-drop.ts new file mode 100644 index 00000000..2e80b487 --- /dev/null +++ b/hooks/use-tag-drop.ts @@ -0,0 +1,115 @@ +"use client"; + +import { useCallback, useState, DragEvent } from "react"; +import { useEmailStore } from "@/stores/email-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useDragDropContext } from "@/contexts/drag-drop-context"; + +interface UseTagDropOptions { + tagId: string; + onSuccess?: (count: number, tagLabel: string) => void; + onError?: (error: string) => void; +} + +interface UseTagDropReturn { + dropHandlers: { + onDragOver: (e: DragEvent) => void; + onDragEnter: (e: DragEvent) => void; + onDragLeave: (e: DragEvent) => void; + onDrop: (e: DragEvent) => void; + }; + isDropTarget: boolean; + isValidDropTarget: boolean; +} + +export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): UseTagDropReturn { + const [isOver, setIsOver] = useState(false); + const { client } = useAuthStore(); + const { fetchEmails, selectedMailbox } = useEmailStore(); + const { isDragging, endDrag } = useDragDropContext(); + + const handleDragOver = useCallback((e: DragEvent) => { + e.preventDefault(); + if (isDragging) { + e.dataTransfer.dropEffect = "copy"; + } else { + e.dataTransfer.dropEffect = "none"; + } + }, [isDragging]); + + const handleDragEnter = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsOver(true); + }, []); + + const handleDragLeave = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + const relatedTarget = e.relatedTarget as Node | null; + if (!e.currentTarget.contains(relatedTarget)) { + setIsOver(false); + } + }, []); + + const handleDrop = useCallback(async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsOver(false); + + if (!client || !isDragging) { + endDrag(); + return; + } + + try { + const emailIdsJson = e.dataTransfer.getData("application/x-email-ids"); + if (!emailIdsJson) { + endDrag(); + return; + } + + const emailIds: string[] = JSON.parse(emailIdsJson); + + for (const emailId of emailIds) { + // Read fresh state to avoid stale closures + const currentEmails = useEmailStore.getState().emails; + 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 + keywords[`$label:${tagId}`] = true; + + await client.updateEmailKeywords(emailId, keywords); + } + + // Refresh the email list + await fetchEmails(client, selectedMailbox); + + onSuccess?.(emailIds.length, tagId); + } catch (error) { + console.error("Failed to tag emails:", error); + onError?.(error instanceof Error ? error.message : "Unknown error"); + } finally { + endDrag(); + } + }, [client, isDragging, tagId, fetchEmails, selectedMailbox, endDrag, onSuccess, onError]); + + return { + dropHandlers: { + onDragOver: handleDragOver, + onDragEnter: handleDragEnter, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }, + isDropTarget: isOver && isDragging, + isValidDropTarget: isOver && isDragging, + }; +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index dfd07f0b..37a520f4 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -499,13 +499,16 @@ export class JMAPClient { } } - async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> { + async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string): Promise<{ emails: Email[], hasMore: boolean, total: number }> { try { const targetAccountId = accountId || this.accountId; - const filter: { inMailbox?: string } = {}; + const filter: { inMailbox?: string; hasKeyword?: string } = {}; if (mailboxId) { filter.inMailbox = mailboxId; } + if (hasKeyword) { + filter.hasKeyword = hasKeyword; + } const response = await this.request([ ["Email/query", { diff --git a/locales/de/common.json b/locales/de/common.json index b101ef3c..abc706ce 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -91,6 +91,7 @@ }, "clear_search": "Suche löschen", "vacation_active": "Abwesenheitsnotiz ist aktiv", + "tags": "Tags", "compose_hint": "Verfassen (c)", "search_placeholder_hint": "E-Mails suchen... (drücke /)", "mail": "E-Mail", @@ -377,6 +378,9 @@ "moved_to_mailbox": "Nach {mailbox} verschoben", "move_failed": "Verschieben fehlgeschlagen", "move_error": "E-Mails konnten nicht in den ausgewählten Ordner verschoben werden", + "email_tagged": "E-Mail markiert", + "emails_tagged": "{count} E-Mails markiert", + "tag_failed": "Markierung fehlgeschlagen", "identity_created": "Identität erfolgreich erstellt", "identity_updated": "Identität erfolgreich aktualisiert", "identity_deleted": "Identität gelöscht", diff --git a/locales/en/common.json b/locales/en/common.json index 255415cc..037480a6 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -94,6 +94,7 @@ }, "clear_search": "Clear search", "vacation_active": "Vacation responder is active", + "tags": "Tags", "mail": "Mail", "nav_label": "Navigation" }, @@ -388,6 +389,9 @@ "moved_to_mailbox": "Moved to {mailbox}", "move_failed": "Move failed", "move_error": "Could not move emails to the selected folder", + "email_tagged": "Email tagged", + "emails_tagged": "{count} emails tagged", + "tag_failed": "Tagging failed", "identity_created": "Identity created successfully", "identity_updated": "Identity updated successfully", "identity_deleted": "Identity deleted", diff --git a/locales/es/common.json b/locales/es/common.json index 1fe411b4..d66c1cf3 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -91,6 +91,7 @@ }, "clear_search": "Limpiar búsqueda", "vacation_active": "Respuesta automática activa", + "tags": "Etiquetas", "compose_hint": "Redactar (c)", "search_placeholder_hint": "Buscar correo... (pulsa /)", "mail": "Correo", @@ -377,6 +378,9 @@ "moved_to_mailbox": "Movido a {mailbox}", "move_failed": "Error al mover", "move_error": "No se pudieron mover los correos a la carpeta seleccionada", + "email_tagged": "Correo etiquetado", + "emails_tagged": "{count} correos etiquetados", + "tag_failed": "Error al etiquetar", "identity_created": "Identidad creada exitosamente", "identity_updated": "Identidad actualizada exitosamente", "identity_deleted": "Identidad eliminada", diff --git a/locales/fr/common.json b/locales/fr/common.json index dc7ece2f..28d9b174 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -93,6 +93,7 @@ }, "clear_search": "Effacer la recherche", "vacation_active": "Répondeur d'absence activé", + "tags": "Étiquettes", "mail": "Messagerie", "nav_label": "Navigation" }, @@ -377,6 +378,9 @@ "moved_to_mailbox": "Déplacé vers {mailbox}", "move_failed": "Échec du déplacement", "move_error": "Impossible de déplacer les emails vers le dossier sélectionné", + "email_tagged": "E-mail étiqueté", + "emails_tagged": "{count} e-mails étiquetés", + "tag_failed": "Échec de l'étiquetage", "identity_created": "Identité créée avec succès", "identity_updated": "Identité mise à jour avec succès", "identity_deleted": "Identité supprimée", diff --git a/locales/it/common.json b/locales/it/common.json index 80103dea..6c3c684f 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -91,6 +91,7 @@ }, "clear_search": "Cancella ricerca", "vacation_active": "Risponditore automatico attivo", + "tags": "Etichette", "compose_hint": "Scrivi (c)", "search_placeholder_hint": "Cerca posta... (premi /)", "mail": "Posta", @@ -377,6 +378,9 @@ "moved_to_mailbox": "Spostato in {mailbox}", "move_failed": "Spostamento non riuscito", "move_error": "Impossibile spostare i messaggi nella cartella selezionata", + "email_tagged": "Email etichettata", + "emails_tagged": "{count} email etichettate", + "tag_failed": "Etichettatura fallita", "identity_created": "Identità creata con successo", "identity_updated": "Identità aggiornata con successo", "identity_deleted": "Identità eliminata", diff --git a/locales/ja/common.json b/locales/ja/common.json index da3e8171..5c2641e4 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -93,6 +93,7 @@ }, "clear_search": "検索をクリア", "vacation_active": "不在応答が有効です", + "tags": "タグ", "mail": "メール", "nav_label": "ナビゲーション" }, @@ -377,6 +378,9 @@ "moved_to_mailbox": "{mailbox}に移動しました", "move_failed": "移動に失敗しました", "move_error": "選択したフォルダにメールを移動できませんでした", + "email_tagged": "メールにタグを付けました", + "emails_tagged": "{count}件のメールにタグを付けました", + "tag_failed": "タグ付けに失敗しました", "identity_created": "送信者情報を作成しました", "identity_updated": "送信者情報を更新しました", "identity_deleted": "送信者情報を削除しました", diff --git a/locales/nl/common.json b/locales/nl/common.json index f2a7ee57..eaeabd84 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -91,6 +91,7 @@ }, "clear_search": "Zoekopdracht wissen", "vacation_active": "Afwezigheidsmelder is actief", + "tags": "Labels", "compose_hint": "Opstellen (c)", "search_placeholder_hint": "E-mail zoeken... (druk /)", "mail": "E-mail", @@ -377,6 +378,9 @@ "moved_to_mailbox": "Verplaatst naar {mailbox}", "move_failed": "Verplaatsen mislukt", "move_error": "Kan e-mails niet verplaatsen naar de geselecteerde map", + "email_tagged": "E-mail gelabeld", + "emails_tagged": "{count} e-mails gelabeld", + "tag_failed": "Labelen mislukt", "identity_created": "Identiteit succesvol aangemaakt", "identity_updated": "Identiteit succesvol bijgewerkt", "identity_deleted": "Identiteit verwijderd", diff --git a/locales/pt/common.json b/locales/pt/common.json index cce73079..97688d55 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -91,6 +91,7 @@ }, "clear_search": "Limpar busca", "vacation_active": "Resposta automática ativa", + "tags": "Etiquetas", "compose_hint": "Compor (c)", "search_placeholder_hint": "Pesquisar e-mail... (pressione /)", "mail": "E-mail", @@ -377,6 +378,9 @@ "moved_to_mailbox": "Movido para {mailbox}", "move_failed": "Falha ao mover", "move_error": "Não foi possível mover os e-mails para a pasta selecionada", + "email_tagged": "E-mail etiquetado", + "emails_tagged": "{count} e-mails etiquetados", + "tag_failed": "Falha ao etiquetar", "identity_created": "Identidade criada com sucesso", "identity_updated": "Identidade atualizada com sucesso", "identity_deleted": "Identidade excluída", diff --git a/stores/email-store.ts b/stores/email-store.ts index ee6e7b17..14604a65 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -29,6 +29,9 @@ interface EmailStore { threadEmailsCache: Map; isLoadingThread: string | null; + // Keyword/tag filter + selectedKeyword: string | null; + // Advanced search state searchFilters: SearchFilters; isAdvancedSearchOpen: boolean; @@ -43,6 +46,7 @@ interface EmailStore { setError: (error: string | null) => void; setSearchQuery: (query: string) => void; setQuota: (quota: { used: number; total: number } | null) => void; + selectKeyword: (keyword: string | null) => void; toggleEmailSelection: (emailId: string) => void; selectRangeEmails: (targetEmailId: string) => void; lastSelectedEmailId: string | null; @@ -126,6 +130,9 @@ export const useEmailStore = create((set, get) => ({ threadEmailsCache: new Map(), isLoadingThread: null, + // Keyword/tag filter + selectedKeyword: null, + // Advanced search state searchFilters: { ...DEFAULT_SEARCH_FILTERS }, isAdvancedSearchOpen: false, @@ -137,10 +144,18 @@ export const useEmailStore = create((set, get) => ({ setEmails: (emails) => set({ emails }), setMailboxes: (mailboxes) => set({ mailboxes }), selectEmail: (email) => set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId }), + selectKeyword: (keyword) => set({ + selectedKeyword: keyword, + selectedEmail: null, + selectedEmailIds: new Set(), + expandedThreadIds: new Set(), + threadEmailsCache: new Map(), + }), selectMailbox: (mailboxId) => set({ selectedMailbox: mailboxId, selectedEmail: null, selectedEmailIds: new Set(), + selectedKeyword: null, expandedThreadIds: new Set(), threadEmailsCache: new Map(), isLoadingThread: null, @@ -231,7 +246,11 @@ export const useEmailStore = create((set, get) => ({ // Get emails per page from settings const emailsPerPage = useSettingsStore.getState().emailsPerPage; - const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0); + // Build keyword filter if a tag is selected + const { selectedKeyword } = get(); + const keywordFilter = selectedKeyword ? `$label:${selectedKeyword}` : undefined; + + const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter); set({ emails: result.emails, hasMoreEmails: result.hasMore, @@ -251,7 +270,7 @@ export const useEmailStore = create((set, get) => ({ }, loadMoreEmails: async (client) => { - const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery } = get(); + const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery, selectedKeyword } = get(); // Don't load if already loading or no more emails if (isLoadingMore || !hasMoreEmails) return; @@ -288,7 +307,7 @@ 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, emails.length); + result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length, selectedKeyword ? `$label:${selectedKeyword}` : undefined); } set({