feat: implement tagging functionality for emails with drag-and-drop support

This commit is contained in:
Linus Rath
2026-03-12 03:10:40 +01:00
parent d8b0f013e8
commit bb40326809
14 changed files with 426 additions and 26 deletions
+23
View File
@@ -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() {
<Sidebar
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
selectedKeyword={selectedKeyword}
onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
+78 -20
View File
@@ -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<string, string> | undefined;
const filter = args.filter as Record<string, unknown> | 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<string, unknown>, list: MockEmail[]): MockEmail[] => {
let result = list;
if (f.operator && Array.isArray(f.conditions)) {
const sub = (f.conditions as Record<string, unknown>[]).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<string, boolean>;
}
// 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<string, boolean>;
const raw = changes.keywords as Record<string, boolean>;
const cleaned: Record<string, boolean> = {};
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.
+151 -1
View File
@@ -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 (
<div
{...(globalDragging ? dropHandlers : {})}
className={cn(
"group w-full flex items-center py-1 lg:py-1 max-lg:py-3 max-lg:min-h-[44px] text-sm transition-all duration-200",
isCollapsed ? "justify-center px-1" : "px-2",
isSelected
? "bg-accent text-accent-foreground"
: "hover:bg-muted text-foreground",
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset"
)}
>
<button
onClick={() => onTagSelect?.(isSelected ? null : kw.id)}
className={cn(
"flex items-center py-1 lg:py-1 max-lg:py-2 px-1 rounded transition-colors duration-150",
isCollapsed ? "justify-center" : "flex-1 text-left"
)}
style={isCollapsed ? undefined : { paddingLeft: '40px' }}
title={isCollapsed ? kw.label : undefined}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", palette?.dot || "bg-gray-400", !isCollapsed && "mr-2")} />
{!isCollapsed && <span className="truncate">{kw.label}</span>}
</button>
</div>
);
}
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<Set<string>>(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({
<MailboxTreeItem
key={node.id}
node={node}
selectedMailbox={selectedMailbox}
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
expandedFolders={expandedFolders}
onMailboxSelect={onMailboxSelect}
onToggleExpand={handleToggleExpand}
@@ -389,6 +459,86 @@ export function Sidebar({
</>
)}
</div>
{/* Tags Section */}
{emailKeywords.length > 0 && (
<>
<div
className={cn(
"group w-full flex items-center py-1 lg:py-1 max-lg:py-3 max-lg:min-h-[44px] text-sm transition-all duration-200 font-medium",
isCollapsed ? "justify-center px-1" : "px-2",
"text-foreground hover:bg-muted"
)}
>
{!isCollapsed && (
<button
onClick={() => {
setTagsExpanded((prev: boolean) => {
const next = !prev;
try { localStorage.setItem('sidebarTagsExpanded', JSON.stringify(next)); } catch { /* */ }
return next;
});
}}
className={cn(
"p-0.5 rounded mr-1 transition-all duration-200",
"hover:bg-muted active:bg-accent"
)}
title={tagsExpanded ? t('collapse_tooltip') : t('expand_tooltip')}
>
{tagsExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
</button>
)}
<button
onClick={() => {
if (isCollapsed) return;
setTagsExpanded((prev: boolean) => {
const next = !prev;
try { localStorage.setItem('sidebarTagsExpanded', JSON.stringify(next)); } catch { /* */ }
return next;
});
}}
className={cn(
"flex items-center py-1 lg:py-1 max-lg:py-2 px-1 rounded",
"transition-colors duration-150",
isCollapsed ? "justify-center" : "flex-1 text-left"
)}
style={isCollapsed ? undefined : { paddingLeft: '4px' }}
title={isCollapsed ? t("tags") : undefined}
>
<Tag className={cn(
"w-4 h-4 flex-shrink-0 transition-colors",
!isCollapsed && "mr-2",
tagsExpanded && "text-primary"
)} />
{!isCollapsed && (
<span className="flex-1 truncate">{t("tags")}</span>
)}
</button>
</div>
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
<div className="relative">
{emailKeywords.map((kw) => {
const isSelected = selectedKeyword === kw.id;
return (
<TagItem
key={kw.id}
kw={kw}
isSelected={isSelected}
isCollapsed={isCollapsed}
onTagSelect={onTagSelect}
/>
);
})}
</div>
)}
</>
)}
</div>
{/* Compose Button */}
+115
View File
@@ -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<HTMLDivElement>) => void;
onDragEnter: (e: DragEvent<HTMLDivElement>) => void;
onDragLeave: (e: DragEvent<HTMLDivElement>) => void;
onDrop: (e: DragEvent<HTMLDivElement>) => 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<HTMLDivElement>) => {
e.preventDefault();
if (isDragging) {
e.dataTransfer.dropEffect = "copy";
} else {
e.dataTransfer.dropEffect = "none";
}
}, [isDragging]);
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsOver(true);
}, []);
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
const relatedTarget = e.relatedTarget as Node | null;
if (!e.currentTarget.contains(relatedTarget)) {
setIsOver(false);
}
}, []);
const handleDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
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,
};
}
+5 -2
View File
@@ -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", {
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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": "送信者情報を削除しました",
+4
View File
@@ -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",
+4
View File
@@ -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",
+22 -3
View File
@@ -29,6 +29,9 @@ interface EmailStore {
threadEmailsCache: Map<string, Email[]>;
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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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({