feat: implement tagging functionality for emails with drag-and-drop support
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,23 +1302,76 @@ 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]);
|
||||
|
||||
// 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));
|
||||
}
|
||||
if (filter?.text) {
|
||||
const q = (filter.text as string).toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
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((f) => f.name?.toLowerCase().includes(q) || f.email.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
|
||||
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
||||
@@ -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.
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
@@ -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", {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "送信者情報を削除しました",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user