mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Tags, pins and $answered/$forwarded/$mdnsent set on a message in a shared/group mailbox opened directly from the "Shared" sidebar section did not persist: they showed until the next reload and were then gone. The call sites resolved the target account as `isUnifiedView ? email.sourceAccountId : undefined`. Outside the unified view that is always `undefined`, so the write went to the reaching client's own account, which the server answers with `notUpdated` / `updated: null` and no error - the same silent no-op that `email-store-shared-folder-actions.test.ts` already covers for the batch actions. Emails fetched from a shared folder this way are undecorated (no `sourceAccountId`); the owner is only recoverable from the selected mailbox. `toggleStar` never had the bug because it routes through `resolveEmailActionContext`, which resolves the owner from the email's source in an aggregate view and from the selected mailbox otherwise. That resolver was reimplemented inline at each keyword call site, and the inline copies only ever covered the aggregate half - so starring a shared message persisted while tagging the same message did not. Move the two keyword writes into store actions built on that resolver and route every call site through them, including the read-receipt and Pro-compose paths that previously passed no accountId at all. The store actions resolve only for an email the store actually knows. A Pro tab fetches its own email and never enters `emails` or `selectedEmail`; for those the selected mailbox says nothing about the message's owner, so guessing from it would send an own-account write to a shared account whenever a shared folder happened to be open. An unknown email keeps the previous behaviour instead. Behaviour on own-account and unified-view messages is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
116 lines
3.5 KiB
TypeScript
116 lines
3.5 KiB
TypeScript
"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, fetchTagCounts, 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 emailState = useEmailStore.getState();
|
|
const email = emailState.emails.find(em => em.id === emailId);
|
|
const keywords = { ...(email?.keywords || {}) };
|
|
|
|
// Add the tag without removing existing ones
|
|
keywords[`$label:${tagId}`] = true;
|
|
|
|
// Routes the write to the email's own account - the email's source in an
|
|
// aggregate view, the selected folder's owner when a shared folder is
|
|
// open directly. (#281)
|
|
await emailState.setEmailKeywords(client, emailId, keywords);
|
|
}
|
|
|
|
// Refresh the email list
|
|
await fetchEmails(client, selectedMailbox);
|
|
|
|
// Refresh tag counts
|
|
fetchTagCounts(client);
|
|
|
|
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, fetchTagCounts, selectedMailbox, endDrag, onSuccess, onError]);
|
|
|
|
return {
|
|
dropHandlers: {
|
|
onDragOver: handleDragOver,
|
|
onDragEnter: handleDragEnter,
|
|
onDragLeave: handleDragLeave,
|
|
onDrop: handleDrop,
|
|
},
|
|
isDropTarget: isOver && isDragging,
|
|
isValidDropTarget: isOver && isDragging,
|
|
};
|
|
}
|