Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -621,6 +621,10 @@ export default function Home() {
|
|||||||
onToggleSpam: async () => {
|
onToggleSpam: async () => {
|
||||||
if (isScheduledView) return;
|
if (isScheduledView) return;
|
||||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
|
// Marking your own outgoing mail as spam makes no sense - the toolbar
|
||||||
|
// and menus hide the action in Sent/Drafts/Scheduled, so the shortcut
|
||||||
|
// is a no-op there too.
|
||||||
|
if (['sent', 'drafts', 'scheduled'].includes(currentMailbox?.role || '')) return;
|
||||||
const isInJunk = currentMailbox?.role === 'junk';
|
const isInJunk = currentMailbox?.role === 'junk';
|
||||||
if (selectedEmailIds.size > 0 && client) {
|
if (selectedEmailIds.size > 0 && client) {
|
||||||
const ids = Array.from(selectedEmailIds);
|
const ids = Array.from(selectedEmailIds);
|
||||||
@@ -1648,6 +1652,45 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTogglePinned = async (emailToPin: Email) => {
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const email = emails.find(e => e.id === emailToPin.id) ?? emailToPin;
|
||||||
|
const isPinned = email.keywords?.['$pinned'] === true;
|
||||||
|
// JMAP keywords are a set of present keys - drop the key to unpin
|
||||||
|
// rather than writing a false value.
|
||||||
|
const keywords = { ...email.keywords };
|
||||||
|
if (isPinned) {
|
||||||
|
delete keywords['$pinned'];
|
||||||
|
} else {
|
||||||
|
keywords['$pinned'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same unified-view routing as color tags: write to the email's own
|
||||||
|
// account via the login it is reachable through. (#281)
|
||||||
|
const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
|
||||||
|
const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined;
|
||||||
|
const pinClient = pinClientId
|
||||||
|
? (useAuthStore.getState().getClientForAccount(pinClientId) ?? client)
|
||||||
|
: client;
|
||||||
|
|
||||||
|
await pinClient.updateEmailKeywords(email.id, keywords, pinAccountId);
|
||||||
|
|
||||||
|
// Patch in place so the icon flips immediately, then refetch the first
|
||||||
|
// page so the mail floats/sinks per the server's pinned-first sort.
|
||||||
|
// Skip the refetch where that sort does not apply (unified views) or
|
||||||
|
// where it would replace a tag-filtered list (refreshCurrentMailbox
|
||||||
|
// fetches by folder only).
|
||||||
|
setEmailKeywordsLocal(email.id, keywords);
|
||||||
|
if (!isUnifiedView && !useEmailStore.getState().selectedKeyword) {
|
||||||
|
void refreshCurrentMailbox(client);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to toggle pin:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSetColorTag = async (emailId: string, color: string | null) => {
|
const handleSetColorTag = async (emailId: string, color: string | null) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
@@ -3038,6 +3081,9 @@ export default function Home() {
|
|||||||
await toggleStar(client, email.id);
|
await toggleStar(client, email.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onTogglePinned={async (email) => {
|
||||||
|
await handleTogglePinned(email);
|
||||||
|
}}
|
||||||
onDelete={async (email) => {
|
onDelete={async (email) => {
|
||||||
await handleDelete(email);
|
await handleDelete(email);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -82,9 +82,16 @@ export async function PUT(request: NextRequest) {
|
|||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
const errorText = await tokenResponse.text();
|
const errorText = await tokenResponse.text();
|
||||||
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
||||||
cookieStore.delete(cookieName);
|
// Drop the refresh token only when the server definitively rejected it
|
||||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
// (invalid/expired/revoked grant). A 5xx or 429 is an outage - keeping
|
||||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
// the cookie lets the session resume once the server is back.
|
||||||
|
const status = tokenResponse.status;
|
||||||
|
if (status === 400 || status === 401 || status === 403) {
|
||||||
|
cookieStore.delete(cookieName);
|
||||||
|
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||||
|
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await tokenResponse.json();
|
const tokens = await tokenResponse.json();
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
MailOpen,
|
MailOpen,
|
||||||
Star,
|
Star,
|
||||||
|
Pin,
|
||||||
|
PinOff,
|
||||||
Trash2,
|
Trash2,
|
||||||
Archive,
|
Archive,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
@@ -59,6 +61,7 @@ interface EmailContextMenuProps {
|
|||||||
onForward?: () => void;
|
onForward?: () => void;
|
||||||
onMarkAsRead?: (read: boolean) => void;
|
onMarkAsRead?: (read: boolean) => void;
|
||||||
onToggleStar?: () => void;
|
onToggleStar?: () => void;
|
||||||
|
onTogglePinned?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onArchive?: () => void;
|
onArchive?: () => void;
|
||||||
onSetColorTag?: (color: string | null) => void;
|
onSetColorTag?: (color: string | null) => void;
|
||||||
@@ -126,6 +129,7 @@ export function EmailContextMenu({
|
|||||||
onForward,
|
onForward,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
|
onTogglePinned,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetColorTag,
|
||||||
@@ -149,10 +153,14 @@ export function EmailContextMenu({
|
|||||||
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
const isPinned = email.keywords?.['$pinned'] === true;
|
||||||
const isDraft = email.keywords?.['$draft'] === true;
|
const isDraft = email.keywords?.['$draft'] === true;
|
||||||
const currentColors = getCurrentColors(email.keywords);
|
const currentColors = getCurrentColors(email.keywords);
|
||||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
|
// Marking your own outgoing mail as spam makes no sense - hide the action
|
||||||
|
// in Sent, Drafts and Scheduled.
|
||||||
|
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
|
||||||
const isScheduled = email.isScheduled === true;
|
const isScheduled = email.isScheduled === true;
|
||||||
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
|
||||||
|
|
||||||
@@ -349,6 +357,15 @@ export function EmailContextMenu({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pin/Unpin - only for single email; pinned mails float to the top of the list */}
|
||||||
|
{!showBatchActions && onTogglePinned && (
|
||||||
|
<ContextMenuItem
|
||||||
|
icon={isPinned ? PinOff : Pin}
|
||||||
|
label={isPinned ? t("unpin") : t("pin")}
|
||||||
|
onClick={() => handleAction(onTogglePinned)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Set tag submenu - only for single email */}
|
{/* Set tag submenu - only for single email */}
|
||||||
{!showBatchActions && (
|
{!showBatchActions && (
|
||||||
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
|
||||||
@@ -385,22 +402,26 @@ export function EmailContextMenu({
|
|||||||
</ContextMenuSubMenu>
|
</ContextMenuSubMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ContextMenuSeparator />
|
{/* Spam - contextual based on folder; pointless on own outgoing mail */}
|
||||||
|
{spamApplicable && (
|
||||||
|
<>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
{/* Spam - contextual based on folder */}
|
<ContextMenuItem
|
||||||
<ContextMenuItem
|
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
|
||||||
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
|
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
|
||||||
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
|
onClick={() =>
|
||||||
onClick={() =>
|
handleAction(
|
||||||
handleAction(
|
showBatchActions
|
||||||
showBatchActions
|
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
|
||||||
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
|
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
|
||||||
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
|
)
|
||||||
)
|
}
|
||||||
}
|
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
|
||||||
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
|
destructive={!isInJunkFolder}
|
||||||
destructive={!isInJunkFolder}
|
/>
|
||||||
/>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ interface EmailHoverActionsProps {
|
|||||||
// the spam quick-action flips to "not spam".
|
// the spam quick-action flips to "not spam".
|
||||||
isInJunk?: boolean;
|
isInJunk?: boolean;
|
||||||
onUndoSpam?: () => void;
|
onUndoSpam?: () => void;
|
||||||
|
// Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent).
|
||||||
|
spamApplicable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_CONFIG: Record<HoverAction, {
|
const ACTION_CONFIG: Record<HoverAction, {
|
||||||
@@ -78,6 +80,7 @@ export function EmailHoverActions({
|
|||||||
onMarkAsSpam,
|
onMarkAsSpam,
|
||||||
isInJunk = false,
|
isInJunk = false,
|
||||||
onUndoSpam,
|
onUndoSpam,
|
||||||
|
spamApplicable = true,
|
||||||
}: EmailHoverActionsProps) {
|
}: EmailHoverActionsProps) {
|
||||||
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
const hoverActions = useSettingsStore((state) => state.hoverActions);
|
||||||
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
|
||||||
@@ -121,6 +124,7 @@ export function EmailHoverActions({
|
|||||||
const actionButtons = hoverActions.map((actionId) => {
|
const actionButtons = hoverActions.map((actionId) => {
|
||||||
const config = ACTION_CONFIG[actionId];
|
const config = ACTION_CONFIG[actionId];
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
if (actionId === "spam" && !spamApplicable) return null;
|
||||||
const Icon = config.icon;
|
const Icon = config.icon;
|
||||||
|
|
||||||
// In a junk context the spam action becomes "not spam".
|
// In a junk context the spam action becomes "not spam".
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { formatDate, stripInvisibleLeading } from "@/lib/utils";
|
|||||||
import { Email } from "@/lib/jmap/types";
|
import { Email } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||||
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
@@ -45,6 +45,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
const isPinned = email.keywords?.['$pinned'] === true;
|
||||||
const isImportant = email.keywords?.["$important"];
|
const isImportant = email.keywords?.["$important"];
|
||||||
const isAnswered = email.keywords?.$answered;
|
const isAnswered = email.keywords?.$answered;
|
||||||
const isForwarded = email.keywords?.$forwarded;
|
const isForwarded = email.keywords?.$forwarded;
|
||||||
@@ -217,6 +218,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5 shrink-0">
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
|
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
|
||||||
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||||
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
|
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
|
||||||
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
@@ -253,6 +255,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
{sender?.name || sender?.email || "Unknown"}
|
{sender?.name || sender?.email || "Unknown"}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isPinned && (
|
||||||
|
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||||
|
)}
|
||||||
{isStarred && (
|
{isStarred && (
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
)}
|
)}
|
||||||
@@ -338,6 +343,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
|||||||
onMarkAsSpam={onMarkAsSpam}
|
onMarkAsSpam={onMarkAsSpam}
|
||||||
onUndoSpam={onUndoSpam}
|
onUndoSpam={onUndoSpam}
|
||||||
isInJunk={currentMailboxRole === 'junk'}
|
isInJunk={currentMailboxRole === 'junk'}
|
||||||
|
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface EmailListProps {
|
|||||||
onForward?: (email: Email) => void;
|
onForward?: (email: Email) => void;
|
||||||
onMarkAsRead?: (email: Email, read: boolean) => void;
|
onMarkAsRead?: (email: Email, read: boolean) => void;
|
||||||
onToggleStar?: (email: Email) => void;
|
onToggleStar?: (email: Email) => void;
|
||||||
|
onTogglePinned?: (email: Email) => void;
|
||||||
onDelete?: (email: Email) => void;
|
onDelete?: (email: Email) => void;
|
||||||
onArchive?: (email: Email) => void;
|
onArchive?: (email: Email) => void;
|
||||||
onSetColorTag?: (emailId: string, color: string | null) => void;
|
onSetColorTag?: (emailId: string, color: string | null) => void;
|
||||||
@@ -64,6 +65,7 @@ export function EmailList({
|
|||||||
onForward,
|
onForward,
|
||||||
onMarkAsRead,
|
onMarkAsRead,
|
||||||
onToggleStar,
|
onToggleStar,
|
||||||
|
onTogglePinned,
|
||||||
onDelete,
|
onDelete,
|
||||||
onArchive,
|
onArchive,
|
||||||
onSetColorTag,
|
onSetColorTag,
|
||||||
@@ -551,6 +553,7 @@ export function EmailList({
|
|||||||
onForward={() => onForward?.(contextMenu.data!)}
|
onForward={() => onForward?.(contextMenu.data!)}
|
||||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
|
||||||
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
|
||||||
|
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
|
||||||
onDelete={() => onDelete?.(contextMenu.data!)}
|
onDelete={() => onDelete?.(contextMenu.data!)}
|
||||||
onArchive={() => onArchive?.(contextMenu.data!)}
|
onArchive={() => onArchive?.(contextMenu.data!)}
|
||||||
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
|
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
|
||||||
|
|||||||
@@ -693,6 +693,9 @@ export function EmailViewer({
|
|||||||
|
|
||||||
// Detect if current mailbox is Junk folder
|
// Detect if current mailbox is Junk folder
|
||||||
const isInJunkFolder = currentMailboxRole === 'junk';
|
const isInJunkFolder = currentMailboxRole === 'junk';
|
||||||
|
// Marking your own outgoing mail as spam makes no sense - hide the action
|
||||||
|
// in Sent, Drafts and Scheduled.
|
||||||
|
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
|
||||||
|
|
||||||
// Detect if the email is a draft
|
// Detect if the email is a draft
|
||||||
const isDraft = email?.keywords?.['$draft'] === true;
|
const isDraft = email?.keywords?.['$draft'] === true;
|
||||||
@@ -2172,6 +2175,26 @@ export function EmailViewer({
|
|||||||
lastBodyHeightRef.current = initialHeight;
|
lastBodyHeightRef.current = initialHeight;
|
||||||
setIframeReady(true);
|
setIframeReady(true);
|
||||||
|
|
||||||
|
// Hide images that fail to load (dead/mixed-content/unreachable external
|
||||||
|
// URLs) rather than leaving the browser's broken-image placeholder and
|
||||||
|
// alt text, which read as stray label text in an otherwise image-only
|
||||||
|
// email (e.g. a blocked "logo" alt). Blocked images already carry a 1x1
|
||||||
|
// transparent pixel (naturalWidth 1) and display:none, so they're skipped.
|
||||||
|
const hideIfBroken = (img: HTMLImageElement) => {
|
||||||
|
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) {
|
||||||
|
img.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
doc.querySelectorAll('img').forEach((el) => {
|
||||||
|
const img = el as HTMLImageElement;
|
||||||
|
if (img.complete) {
|
||||||
|
hideIfBroken(img);
|
||||||
|
} else {
|
||||||
|
img.addEventListener('error', () => { img.style.display = 'none'; }, { once: true });
|
||||||
|
img.addEventListener('load', () => hideIfBroken(img), { once: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Make links open in new tab
|
// Make links open in new tab
|
||||||
doc.querySelectorAll('a').forEach(a => {
|
doc.querySelectorAll('a').forEach(a => {
|
||||||
a.setAttribute('target', '_blank');
|
a.setAttribute('target', '_blank');
|
||||||
@@ -2902,7 +2925,7 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spam */}
|
{/* Spam */}
|
||||||
{(onMarkAsSpam || onUndoSpam) && (
|
{spamApplicable && (onMarkAsSpam || onUndoSpam) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -3136,7 +3159,7 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Overflow: spam */}
|
{/* Overflow: spam */}
|
||||||
{(onMarkAsSpam || onUndoSpam) && (
|
{spamApplicable && (onMarkAsSpam || onUndoSpam) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
|
||||||
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(7) ? "" : "sm:hidden")}
|
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(7) ? "" : "sm:hidden")}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
|||||||
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
|
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
||||||
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
@@ -78,6 +78,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const tBatch = useTranslations('email_list.batch_actions');
|
const tBatch = useTranslations('email_list.batch_actions');
|
||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
|
const isPinned = email.keywords?.['$pinned'] === true;
|
||||||
const isAnswered = email.keywords?.$answered;
|
const isAnswered = email.keywords?.$answered;
|
||||||
const isForwarded = email.keywords?.$forwarded;
|
const isForwarded = email.keywords?.$forwarded;
|
||||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||||
@@ -264,6 +265,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5 shrink-0">
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
|
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
|
||||||
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||||
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
@@ -316,6 +318,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
{sender?.name || sender?.email || "Unknown"}
|
{sender?.name || sender?.email || "Unknown"}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isPinned && (
|
||||||
|
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||||
|
)}
|
||||||
{isStarred && (
|
{isStarred && (
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
)}
|
)}
|
||||||
@@ -405,6 +410,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
onMarkAsSpam={onMarkAsSpam}
|
onMarkAsSpam={onMarkAsSpam}
|
||||||
onUndoSpam={onUndoSpam}
|
onUndoSpam={onUndoSpam}
|
||||||
isInJunk={currentMailboxRole === 'junk'}
|
isInJunk={currentMailboxRole === 'junk'}
|
||||||
|
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -442,7 +448,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||||
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
||||||
@@ -722,6 +728,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5 shrink-0">
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
|
{hasPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
|
||||||
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
|
||||||
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
@@ -786,6 +793,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
{emailCount}
|
{emailCount}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
{hasPinned && (
|
||||||
|
<Pin className="w-3.5 h-3.5 text-primary" />
|
||||||
|
)}
|
||||||
{hasStarred && (
|
{hasStarred && (
|
||||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||||
)}
|
)}
|
||||||
@@ -875,6 +885,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
|
||||||
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
|
||||||
isInJunk={currentMailboxRole === 'junk'}
|
isInJunk={currentMailboxRole === 'junk'}
|
||||||
|
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,37 +116,47 @@ describe('groupEmailsByThread', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('sortThreadGroups', () => {
|
describe('sortThreadGroups', () => {
|
||||||
|
const makeGroup = (threadId: string, receivedAt: string, hasPinned = false): ThreadGroup => ({
|
||||||
|
threadId,
|
||||||
|
emails: [makeEmail({ receivedAt })],
|
||||||
|
latestEmail: makeEmail({ receivedAt }),
|
||||||
|
participantNames: ['A'],
|
||||||
|
hasUnread: false,
|
||||||
|
hasStarred: false,
|
||||||
|
hasPinned,
|
||||||
|
hasAttachment: false,
|
||||||
|
hasAnswered: false,
|
||||||
|
hasForwarded: false,
|
||||||
|
emailCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
it('sorts groups by latestEmail.receivedAt descending', () => {
|
it('sorts groups by latestEmail.receivedAt descending', () => {
|
||||||
const groups: ThreadGroup[] = [
|
const groups = [
|
||||||
{
|
makeGroup('old', '2024-01-01T00:00:00Z'),
|
||||||
threadId: 'old',
|
makeGroup('new', '2024-06-01T00:00:00Z'),
|
||||||
emails: [makeEmail({ receivedAt: '2024-01-01T00:00:00Z' })],
|
|
||||||
latestEmail: makeEmail({ receivedAt: '2024-01-01T00:00:00Z' }),
|
|
||||||
participantNames: ['A'],
|
|
||||||
hasUnread: false,
|
|
||||||
hasStarred: false,
|
|
||||||
hasAttachment: false,
|
|
||||||
hasAnswered: false,
|
|
||||||
hasForwarded: false,
|
|
||||||
emailCount: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
threadId: 'new',
|
|
||||||
emails: [makeEmail({ receivedAt: '2024-06-01T00:00:00Z' })],
|
|
||||||
latestEmail: makeEmail({ receivedAt: '2024-06-01T00:00:00Z' }),
|
|
||||||
participantNames: ['B'],
|
|
||||||
hasUnread: false,
|
|
||||||
hasStarred: false,
|
|
||||||
hasAttachment: false,
|
|
||||||
hasAnswered: false,
|
|
||||||
hasForwarded: false,
|
|
||||||
emailCount: 1,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
const sorted = sortThreadGroups(groups);
|
const sorted = sortThreadGroups(groups);
|
||||||
expect(sorted[0].threadId).toBe('new');
|
expect(sorted[0].threadId).toBe('new');
|
||||||
expect(sorted[1].threadId).toBe('old');
|
expect(sorted[1].threadId).toBe('old');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps pinned threads on top regardless of date', () => {
|
||||||
|
const groups = [
|
||||||
|
makeGroup('newest', '2024-06-01T00:00:00Z'),
|
||||||
|
makeGroup('old-pinned', '2024-01-01T00:00:00Z', true),
|
||||||
|
makeGroup('mid', '2024-03-01T00:00:00Z'),
|
||||||
|
];
|
||||||
|
const sorted = sortThreadGroups(groups);
|
||||||
|
expect(sorted.map(g => g.threadId)).toEqual(['old-pinned', 'newest', 'mid']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects hasPinned from the $pinned keyword', () => {
|
||||||
|
const emails = [
|
||||||
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||||
|
makeEmail({ id: 'e2', keywords: { $seen: true, '$pinned': true } }),
|
||||||
|
];
|
||||||
|
expect(groupEmailsByThread(emails)[0].hasPinned).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getThreadParticipants', () => {
|
describe('getThreadParticipants', () => {
|
||||||
@@ -188,6 +198,7 @@ describe('mergeThreadEmails', () => {
|
|||||||
participantNames: ['Alice'],
|
participantNames: ['Alice'],
|
||||||
hasUnread: false,
|
hasUnread: false,
|
||||||
hasStarred: false,
|
hasStarred: false,
|
||||||
|
hasPinned: false,
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
hasAnswered: false,
|
hasAnswered: false,
|
||||||
hasForwarded: false,
|
hasForwarded: false,
|
||||||
@@ -210,6 +221,7 @@ describe('mergeThreadEmails', () => {
|
|||||||
participantNames: ['Alice'],
|
participantNames: ['Alice'],
|
||||||
hasUnread: false,
|
hasUnread: false,
|
||||||
hasStarred: false,
|
hasStarred: false,
|
||||||
|
hasPinned: false,
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
hasAnswered: false,
|
hasAnswered: false,
|
||||||
hasForwarded: false,
|
hasForwarded: false,
|
||||||
|
|||||||
@@ -151,12 +151,16 @@ export class DemoJMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
// ── Emails ────────────────────────────────────────────────────
|
// ── Emails ────────────────────────────────────────────────────
|
||||||
|
|
||||||
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, pinnedFirst?: boolean): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
||||||
let filtered = this.data.emails;
|
let filtered = this.data.emails;
|
||||||
if (mailboxId) {
|
if (mailboxId) {
|
||||||
filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
|
filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
|
||||||
}
|
}
|
||||||
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
const pinRank = (e: Email) => (pinnedFirst && e.keywords?.['$pinned'] ? 1 : 0);
|
||||||
|
filtered.sort((a, b) =>
|
||||||
|
pinRank(b) - pinRank(a) ||
|
||||||
|
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||||
|
);
|
||||||
const total = filtered.length;
|
const total = filtered.length;
|
||||||
const emails = filtered.slice(position, position + limit);
|
const emails = filtered.slice(position, position + limit);
|
||||||
return { emails, hasMore: position + limit < total, total };
|
return { emails, hasMore: position + limit < total, total };
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ export interface IJMAPClient {
|
|||||||
deleteMailbox(mailboxId: string): Promise<void>;
|
deleteMailbox(mailboxId: string): Promise<void>;
|
||||||
|
|
||||||
// ── Emails ────────────────────────────────────────────────────
|
// ── Emails ────────────────────────────────────────────────────
|
||||||
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
// `pinnedFirst` sorts emails carrying the $pinned keyword to the top
|
||||||
|
// (server-side hasKeyword sort comparator, RFC 8621), then receivedAt desc.
|
||||||
|
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||||
getEmailsInMailbox(mailboxId: string): Promise<Email[]>;
|
getEmailsInMailbox(mailboxId: string): Promise<Email[]>;
|
||||||
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
|
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
|
||||||
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
|
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
|
||||||
|
|||||||
+13
-2
@@ -1062,7 +1062,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
||||||
try {
|
try {
|
||||||
const targetAccountId = accountId || this.accountId;
|
const targetAccountId = accountId || this.accountId;
|
||||||
const filter: { inMailbox?: string; hasKeyword?: string } = {};
|
const filter: { inMailbox?: string; hasKeyword?: string } = {};
|
||||||
@@ -1072,12 +1072,20 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (hasKeyword) {
|
if (hasKeyword) {
|
||||||
filter.hasKeyword = hasKeyword;
|
filter.hasKeyword = hasKeyword;
|
||||||
}
|
}
|
||||||
|
// Pinned-first uses the hasKeyword sort comparator (RFC 8621 §4.4.2);
|
||||||
|
// every page of a view must use the same sort or pagination tears.
|
||||||
|
const sort = pinnedFirst
|
||||||
|
? [
|
||||||
|
{ property: "hasKeyword", keyword: "$pinned", isAscending: false },
|
||||||
|
{ property: "receivedAt", isAscending: false },
|
||||||
|
]
|
||||||
|
: [{ property: "receivedAt", isAscending: false }];
|
||||||
|
|
||||||
const response = await this.request([
|
const response = await this.request([
|
||||||
["Email/query", {
|
["Email/query", {
|
||||||
accountId: targetAccountId,
|
accountId: targetAccountId,
|
||||||
filter,
|
filter,
|
||||||
sort: [{ property: "receivedAt", isAscending: false }],
|
sort,
|
||||||
limit,
|
limit,
|
||||||
position,
|
position,
|
||||||
calculateTotal: true,
|
calculateTotal: true,
|
||||||
@@ -1096,7 +1104,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const emails = (getResponse.list || []) as Email[];
|
const emails = (getResponse.list || []) as Email[];
|
||||||
// Sort client-side as safety net - some servers may not honour
|
// Sort client-side as safety net - some servers may not honour
|
||||||
// the query sort for large mailboxes without additional filters.
|
// the query sort for large mailboxes without additional filters.
|
||||||
|
// Must mirror the query sort, or it would undo the pinned-first order.
|
||||||
|
const pinRank = (e: Email) => (pinnedFirst && e.keywords?.['$pinned'] ? 1 : 0);
|
||||||
emails.sort((a: Email, b: Email) =>
|
emails.sort((a: Email, b: Email) =>
|
||||||
|
pinRank(b) - pinRank(a) ||
|
||||||
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||||
);
|
);
|
||||||
const total = queryResponse?.total || 0;
|
const total = queryResponse?.total || 0;
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ export interface ThreadGroup {
|
|||||||
participantNames: string[];// Unique participant names
|
participantNames: string[];// Unique participant names
|
||||||
hasUnread: boolean; // Any unread emails in thread
|
hasUnread: boolean; // Any unread emails in thread
|
||||||
hasStarred: boolean; // Any starred emails in thread
|
hasStarred: boolean; // Any starred emails in thread
|
||||||
|
hasPinned: boolean; // Any pinned emails in thread ($pinned keyword)
|
||||||
hasAttachment: boolean; // Any email has attachment
|
hasAttachment: boolean; // Any email has attachment
|
||||||
hasAnswered: boolean; // Any email has been replied to
|
hasAnswered: boolean; // Any email has been replied to
|
||||||
hasForwarded: boolean; // Any email has been forwarded
|
hasForwarded: boolean; // Any email has been forwarded
|
||||||
|
|||||||
+10
-2
@@ -44,9 +44,10 @@ export function groupEmailsByThread(
|
|||||||
// Collect unique participant names from all emails in thread
|
// Collect unique participant names from all emails in thread
|
||||||
const participantNames = getThreadParticipants(sortedEmails);
|
const participantNames = getThreadParticipants(sortedEmails);
|
||||||
|
|
||||||
// Check for unread, starred, and attachments
|
// Check for unread, starred, pinned, and attachments
|
||||||
const hasUnread = sortedEmails.some(e => !e.keywords?.$seen);
|
const hasUnread = sortedEmails.some(e => !e.keywords?.$seen);
|
||||||
const hasStarred = sortedEmails.some(e => e.keywords?.$flagged);
|
const hasStarred = sortedEmails.some(e => e.keywords?.$flagged);
|
||||||
|
const hasPinned = sortedEmails.some(e => e.keywords?.['$pinned']);
|
||||||
const hasAttachment = sortedEmails.some(e => e.hasAttachment);
|
const hasAttachment = sortedEmails.some(e => e.hasAttachment);
|
||||||
const hasAnswered = sortedEmails.some(e => e.keywords?.$answered);
|
const hasAnswered = sortedEmails.some(e => e.keywords?.$answered);
|
||||||
const hasForwarded = sortedEmails.some(e => e.keywords?.$forwarded);
|
const hasForwarded = sortedEmails.some(e => e.keywords?.$forwarded);
|
||||||
@@ -58,6 +59,7 @@ export function groupEmailsByThread(
|
|||||||
participantNames,
|
participantNames,
|
||||||
hasUnread,
|
hasUnread,
|
||||||
hasStarred,
|
hasStarred,
|
||||||
|
hasPinned,
|
||||||
hasAttachment,
|
hasAttachment,
|
||||||
hasAnswered,
|
hasAnswered,
|
||||||
hasForwarded,
|
hasForwarded,
|
||||||
@@ -70,10 +72,14 @@ export function groupEmailsByThread(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorts thread groups by their latest email's receivedAt date (newest first).
|
* Sorts thread groups by their latest email's receivedAt date (newest first).
|
||||||
|
* Threads containing a pinned email ($pinned keyword) stay on top, mirroring
|
||||||
|
* the server-side pinned-first sort of the email list.
|
||||||
*/
|
*/
|
||||||
export function sortThreadGroups(groups: ThreadGroup[]): ThreadGroup[] {
|
export function sortThreadGroups(groups: ThreadGroup[]): ThreadGroup[] {
|
||||||
return [...groups].sort(
|
return [...groups].sort(
|
||||||
(a, b) => new Date(b.latestEmail.receivedAt).getTime() - new Date(a.latestEmail.receivedAt).getTime()
|
(a, b) =>
|
||||||
|
(b.hasPinned ? 1 : 0) - (a.hasPinned ? 1 : 0) ||
|
||||||
|
new Date(b.latestEmail.receivedAt).getTime() - new Date(a.latestEmail.receivedAt).getTime()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +142,7 @@ export function mergeThreadEmails(
|
|||||||
const participantNames = getThreadParticipants(mergedEmails);
|
const participantNames = getThreadParticipants(mergedEmails);
|
||||||
const hasUnread = mergedEmails.some(e => !e.keywords?.$seen);
|
const hasUnread = mergedEmails.some(e => !e.keywords?.$seen);
|
||||||
const hasStarred = mergedEmails.some(e => e.keywords?.$flagged);
|
const hasStarred = mergedEmails.some(e => e.keywords?.$flagged);
|
||||||
|
const hasPinned = mergedEmails.some(e => e.keywords?.['$pinned']);
|
||||||
const hasAttachment = mergedEmails.some(e => e.hasAttachment);
|
const hasAttachment = mergedEmails.some(e => e.hasAttachment);
|
||||||
const hasAnswered = mergedEmails.some(e => e.keywords?.$answered);
|
const hasAnswered = mergedEmails.some(e => e.keywords?.$answered);
|
||||||
const hasForwarded = mergedEmails.some(e => e.keywords?.$forwarded);
|
const hasForwarded = mergedEmails.some(e => e.keywords?.$forwarded);
|
||||||
@@ -147,6 +154,7 @@ export function mergeThreadEmails(
|
|||||||
participantNames,
|
participantNames,
|
||||||
hasUnread,
|
hasUnread,
|
||||||
hasStarred,
|
hasStarred,
|
||||||
|
hasPinned,
|
||||||
hasAttachment,
|
hasAttachment,
|
||||||
hasAnswered,
|
hasAnswered,
|
||||||
hasForwarded,
|
hasForwarded,
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Označit jako nepřečtené",
|
"mark_unread": "Označit jako nepřečtené",
|
||||||
"star": "Označit hvězdičkou",
|
"star": "Označit hvězdičkou",
|
||||||
"unstar": "Odebrat hvězdičku",
|
"unstar": "Odebrat hvězdičku",
|
||||||
|
"pin": "Připnout",
|
||||||
|
"unpin": "Odepnout",
|
||||||
"move_to": "Přesunout do...",
|
"move_to": "Přesunout do...",
|
||||||
"archive": "Archivovat",
|
"archive": "Archivovat",
|
||||||
"delete": "Odstranit",
|
"delete": "Odstranit",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Markér som ulæst",
|
"mark_unread": "Markér som ulæst",
|
||||||
"star": "Stjernemarkér",
|
"star": "Stjernemarkér",
|
||||||
"unstar": "Fjern stjerne",
|
"unstar": "Fjern stjerne",
|
||||||
|
"pin": "Fastgør",
|
||||||
|
"unpin": "Frigør",
|
||||||
"move_to": "Flyt til...",
|
"move_to": "Flyt til...",
|
||||||
"archive": "Arkivér",
|
"archive": "Arkivér",
|
||||||
"delete": "Slet",
|
"delete": "Slet",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Als ungelesen markieren",
|
"mark_unread": "Als ungelesen markieren",
|
||||||
"star": "Stern hinzufügen",
|
"star": "Stern hinzufügen",
|
||||||
"unstar": "Stern entfernen",
|
"unstar": "Stern entfernen",
|
||||||
|
"pin": "Anheften",
|
||||||
|
"unpin": "Lösen",
|
||||||
"move_to": "Verschieben nach...",
|
"move_to": "Verschieben nach...",
|
||||||
"archive": "Archivieren",
|
"archive": "Archivieren",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Mark as Unread",
|
"mark_unread": "Mark as Unread",
|
||||||
"star": "Star",
|
"star": "Star",
|
||||||
"unstar": "Unstar",
|
"unstar": "Unstar",
|
||||||
|
"pin": "Pin",
|
||||||
|
"unpin": "Unpin",
|
||||||
"move_to": "Move to...",
|
"move_to": "Move to...",
|
||||||
"archive": "Archive",
|
"archive": "Archive",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Marcar como No Leído",
|
"mark_unread": "Marcar como No Leído",
|
||||||
"star": "Destacar",
|
"star": "Destacar",
|
||||||
"unstar": "Quitar Destacado",
|
"unstar": "Quitar Destacado",
|
||||||
|
"pin": "Anclar",
|
||||||
|
"unpin": "Desanclar",
|
||||||
"move_to": "Mover a...",
|
"move_to": "Mover a...",
|
||||||
"archive": "Archivar",
|
"archive": "Archivar",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "علامتگذاری خوانده نشده",
|
"mark_unread": "علامتگذاری خوانده نشده",
|
||||||
"star": "ستارهدار",
|
"star": "ستارهدار",
|
||||||
"unstar": "حذف ستاره",
|
"unstar": "حذف ستاره",
|
||||||
|
"pin": "سنجاق کردن",
|
||||||
|
"unpin": "برداشتن سنجاق",
|
||||||
"move_to": "انتقال به...",
|
"move_to": "انتقال به...",
|
||||||
"archive": "بایگانی",
|
"archive": "بایگانی",
|
||||||
"delete": "حذف",
|
"delete": "حذف",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Marquer comme non lu",
|
"mark_unread": "Marquer comme non lu",
|
||||||
"star": "Marquer comme favori",
|
"star": "Marquer comme favori",
|
||||||
"unstar": "Retirer des favoris",
|
"unstar": "Retirer des favoris",
|
||||||
|
"pin": "Épingler",
|
||||||
|
"unpin": "Désépingler",
|
||||||
"move_to": "Déplacer vers...",
|
"move_to": "Déplacer vers...",
|
||||||
"archive": "Archiver",
|
"archive": "Archiver",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Olvasatlannak jelölés",
|
"mark_unread": "Olvasatlannak jelölés",
|
||||||
"star": "Csillagozás",
|
"star": "Csillagozás",
|
||||||
"unstar": "Csillagozás megszüntetése",
|
"unstar": "Csillagozás megszüntetése",
|
||||||
|
"pin": "Rögzítés",
|
||||||
|
"unpin": "Rögzítés feloldása",
|
||||||
"move_to": "Áthelyezés ide...",
|
"move_to": "Áthelyezés ide...",
|
||||||
"archive": "Archiválás",
|
"archive": "Archiválás",
|
||||||
"delete": "Törlés",
|
"delete": "Törlés",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Segna come non letto",
|
"mark_unread": "Segna come non letto",
|
||||||
"star": "Aggiungi stella",
|
"star": "Aggiungi stella",
|
||||||
"unstar": "Rimuovi stella",
|
"unstar": "Rimuovi stella",
|
||||||
|
"pin": "Fissa",
|
||||||
|
"unpin": "Non fissare più",
|
||||||
"move_to": "Sposta in...",
|
"move_to": "Sposta in...",
|
||||||
"archive": "Archivia",
|
"archive": "Archivia",
|
||||||
"delete": "Elimina",
|
"delete": "Elimina",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "未読にする",
|
"mark_unread": "未読にする",
|
||||||
"star": "スターを付ける",
|
"star": "スターを付ける",
|
||||||
"unstar": "スターを外す",
|
"unstar": "スターを外す",
|
||||||
|
"pin": "ピン留め",
|
||||||
|
"unpin": "ピン留めを外す",
|
||||||
"move_to": "移動...",
|
"move_to": "移動...",
|
||||||
"archive": "アーカイブ",
|
"archive": "アーカイブ",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "읽지 않은 상태로 표시",
|
"mark_unread": "읽지 않은 상태로 표시",
|
||||||
"star": "별표 달기",
|
"star": "별표 달기",
|
||||||
"unstar": "별표 해제",
|
"unstar": "별표 해제",
|
||||||
|
"pin": "고정",
|
||||||
|
"unpin": "고정 해제",
|
||||||
"move_to": "이동...",
|
"move_to": "이동...",
|
||||||
"archive": "보관",
|
"archive": "보관",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Atzīmēt kā nelasītu",
|
"mark_unread": "Atzīmēt kā nelasītu",
|
||||||
"star": "Pievienot zvaigznīti",
|
"star": "Pievienot zvaigznīti",
|
||||||
"unstar": "Noņemt zvaigznīti",
|
"unstar": "Noņemt zvaigznīti",
|
||||||
|
"pin": "Piespraust",
|
||||||
|
"unpin": "Atspraust",
|
||||||
"move_to": "Pārvietot uz...",
|
"move_to": "Pārvietot uz...",
|
||||||
"archive": "Arhivēt",
|
"archive": "Arhivēt",
|
||||||
"delete": "Dzēst",
|
"delete": "Dzēst",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Markeren als ongelezen",
|
"mark_unread": "Markeren als ongelezen",
|
||||||
"star": "Ster toevoegen",
|
"star": "Ster toevoegen",
|
||||||
"unstar": "Ster verwijderen",
|
"unstar": "Ster verwijderen",
|
||||||
|
"pin": "Vastmaken",
|
||||||
|
"unpin": "Losmaken",
|
||||||
"move_to": "Verplaatsen naar...",
|
"move_to": "Verplaatsen naar...",
|
||||||
"archive": "Archiveren",
|
"archive": "Archiveren",
|
||||||
"delete": "Verwijderen",
|
"delete": "Verwijderen",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Oznacz jako nieprzeczytane",
|
"mark_unread": "Oznacz jako nieprzeczytane",
|
||||||
"star": "Oznacz gwiazdką",
|
"star": "Oznacz gwiazdką",
|
||||||
"unstar": "Usuń gwiazdkę",
|
"unstar": "Usuń gwiazdkę",
|
||||||
|
"pin": "Przypnij",
|
||||||
|
"unpin": "Odepnij",
|
||||||
"move_to": "Przenieś do...",
|
"move_to": "Przenieś do...",
|
||||||
"archive": "Archiwizuj",
|
"archive": "Archiwizuj",
|
||||||
"delete": "Usuń",
|
"delete": "Usuń",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Marcar como Não Lido",
|
"mark_unread": "Marcar como Não Lido",
|
||||||
"star": "Adicionar Estrela",
|
"star": "Adicionar Estrela",
|
||||||
"unstar": "Remover Estrela",
|
"unstar": "Remover Estrela",
|
||||||
|
"pin": "Fixar",
|
||||||
|
"unpin": "Desafixar",
|
||||||
"move_to": "Mover para...",
|
"move_to": "Mover para...",
|
||||||
"archive": "Arquivar",
|
"archive": "Arquivar",
|
||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Poloneză",
|
"pl": "Poloneză",
|
||||||
"pt": "Portugheză",
|
"pt": "Portugheză",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovacă",
|
||||||
"uk": "Ukrainiană",
|
"uk": "Ukrainiană",
|
||||||
"zh": "Chineză simplificată",
|
"zh": "Chineză simplificată",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Marcați ca necitit",
|
"mark_unread": "Marcați ca necitit",
|
||||||
"star": "Stea",
|
"star": "Stea",
|
||||||
"unstar": "Anulează marcarea cu stea",
|
"unstar": "Anulează marcarea cu stea",
|
||||||
|
"pin": "Fixează",
|
||||||
|
"unpin": "Anulează fixarea",
|
||||||
"move_to": "Mergi la...",
|
"move_to": "Mergi la...",
|
||||||
"archive": "Arhivează",
|
"archive": "Arhivează",
|
||||||
"delete": "Șterge",
|
"delete": "Șterge",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Отметить как непрочитанное",
|
"mark_unread": "Отметить как непрочитанное",
|
||||||
"star": "Добавить звёздочку",
|
"star": "Добавить звёздочку",
|
||||||
"unstar": "Убрать звёздочку",
|
"unstar": "Убрать звёздочку",
|
||||||
|
"pin": "Закрепить",
|
||||||
|
"unpin": "Открепить",
|
||||||
"move_to": "Переместить в...",
|
"move_to": "Переместить в...",
|
||||||
"archive": "В архив",
|
"archive": "В архив",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
|
|||||||
+16
-2
@@ -635,7 +635,9 @@
|
|||||||
"validation": {
|
"validation": {
|
||||||
"recipient_required": "Pridajte príjemcu na odoslanie",
|
"recipient_required": "Pridajte príjemcu na odoslanie",
|
||||||
"subject_required": "Pridajte predmet",
|
"subject_required": "Pridajte predmet",
|
||||||
"body_required": "Napíšte správu alebo priložte súbor"
|
"body_required": "Napíšte správu alebo priložte súbor",
|
||||||
|
"attachments_uploading": "Prílohy sa stále nahrávajú - odošle sa hneď po ich dokončení",
|
||||||
|
"attachment_upload_failed": "Neodoslané - prílohu sa nepodarilo nahrať. Odstráňte ju a skúste to znova."
|
||||||
},
|
},
|
||||||
"upload_progress": "Nahrávanie {uploaded} / {total}",
|
"upload_progress": "Nahrávanie {uploaded} / {total}",
|
||||||
"upload_cancel": "Zrušiť nahrávanie",
|
"upload_cancel": "Zrušiť nahrávanie",
|
||||||
@@ -675,7 +677,9 @@
|
|||||||
"recipient_edit_email": "Upraviť e-mailovú adresu",
|
"recipient_edit_email": "Upraviť e-mailovú adresu",
|
||||||
"recipient_edit_name": "Upraviť zobrazené meno",
|
"recipient_edit_name": "Upraviť zobrazené meno",
|
||||||
"recipient_email_placeholder": "E-mailová adresa",
|
"recipient_email_placeholder": "E-mailová adresa",
|
||||||
"recipient_name_placeholder": "Zobrazené meno"
|
"recipient_name_placeholder": "Zobrazené meno",
|
||||||
|
"autocomplete_search_server": "Hľadať na serveri",
|
||||||
|
"autocomplete_searching": "Hľadanie..."
|
||||||
},
|
},
|
||||||
"confirm_dialog": {
|
"confirm_dialog": {
|
||||||
"confirm": "Potvrdiť",
|
"confirm": "Potvrdiť",
|
||||||
@@ -1051,6 +1055,14 @@
|
|||||||
"preview_this_week": "Tento týždeň:",
|
"preview_this_week": "Tento týždeň:",
|
||||||
"preview_older": "Staršie:"
|
"preview_older": "Staršie:"
|
||||||
},
|
},
|
||||||
|
"date_locale": {
|
||||||
|
"label": "Date format region",
|
||||||
|
"description": "How numeric dates are ordered (day, month, year)",
|
||||||
|
"auto": "Automatic (match language)",
|
||||||
|
"iso": "ISO 8601 (YYYY-MM-DD)",
|
||||||
|
"dmy": "Day/Month/Year",
|
||||||
|
"mdy": "Month/Day/Year"
|
||||||
|
},
|
||||||
"time_format": {
|
"time_format": {
|
||||||
"label": "Formát času",
|
"label": "Formát času",
|
||||||
"description": "Vyberte 12-hodinový alebo 24-hodinový formát času",
|
"description": "Vyberte 12-hodinový alebo 24-hodinový formát času",
|
||||||
@@ -1943,6 +1955,8 @@
|
|||||||
"mark_unread": "Označiť ako neprečítané",
|
"mark_unread": "Označiť ako neprečítané",
|
||||||
"star": "Hviezdička",
|
"star": "Hviezdička",
|
||||||
"unstar": "Odstrániť hviezdičku",
|
"unstar": "Odstrániť hviezdičku",
|
||||||
|
"pin": "Pripnúť",
|
||||||
|
"unpin": "Odopnúť",
|
||||||
"move_to": "Presunúť do...",
|
"move_to": "Presunúť do...",
|
||||||
"archive": "Archivovať",
|
"archive": "Archivovať",
|
||||||
"delete": "Odstrániť",
|
"delete": "Odstrániť",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Okunmadı Olarak İşaretle",
|
"mark_unread": "Okunmadı Olarak İşaretle",
|
||||||
"star": "Yıldız Ekle",
|
"star": "Yıldız Ekle",
|
||||||
"unstar": "Yıldızı Kaldır",
|
"unstar": "Yıldızı Kaldır",
|
||||||
|
"pin": "Sabitle",
|
||||||
|
"unpin": "Sabitlemeyi kaldır",
|
||||||
"move_to": "Şuraya taşı...",
|
"move_to": "Şuraya taşı...",
|
||||||
"archive": "Arşivle",
|
"archive": "Arşivle",
|
||||||
"delete": "Sil",
|
"delete": "Sil",
|
||||||
|
|||||||
@@ -806,6 +806,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"polish": "Polski",
|
"polish": "Polski",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "Позначити як непрочитане",
|
"mark_unread": "Позначити як непрочитане",
|
||||||
"star": "зірка",
|
"star": "зірка",
|
||||||
"unstar": "Зняти зірочку",
|
"unstar": "Зняти зірочку",
|
||||||
|
"pin": "Закріпити",
|
||||||
|
"unpin": "Відкріпити",
|
||||||
"move_to": "Перейти до...",
|
"move_to": "Перейти до...",
|
||||||
"archive": "Архів",
|
"archive": "Архів",
|
||||||
"delete": "Видалити",
|
"delete": "Видалити",
|
||||||
|
|||||||
@@ -808,6 +808,7 @@
|
|||||||
"pl": "Polski",
|
"pl": "Polski",
|
||||||
"pt": "Português",
|
"pt": "Português",
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
"sk": "Slovenčina",
|
||||||
"uk": "Українська",
|
"uk": "Українська",
|
||||||
"zh": "简体中文",
|
"zh": "简体中文",
|
||||||
"fa": "فارسی",
|
"fa": "فارسی",
|
||||||
@@ -1954,6 +1955,8 @@
|
|||||||
"mark_unread": "标记为未读",
|
"mark_unread": "标记为未读",
|
||||||
"star": "加星标",
|
"star": "加星标",
|
||||||
"unstar": "取消星标",
|
"unstar": "取消星标",
|
||||||
|
"pin": "固定",
|
||||||
|
"unpin": "取消固定",
|
||||||
"move_to": "移动到…",
|
"move_to": "移动到…",
|
||||||
"archive": "归档",
|
"archive": "归档",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export async function proxy(request: NextRequest) {
|
|||||||
`script-src ${scriptSrc}`,
|
`script-src ${scriptSrc}`,
|
||||||
`style-src 'self' 'unsafe-inline'`,
|
`style-src 'self' 'unsafe-inline'`,
|
||||||
`img-src 'self' data: blob: https:`,
|
`img-src 'self' data: blob: https:`,
|
||||||
`font-src 'self'`,
|
`font-src 'self' https: data:`,
|
||||||
`connect-src ${connectSrc}`,
|
`connect-src ${connectSrc}`,
|
||||||
frameSrc,
|
frameSrc,
|
||||||
`object-src 'self' blob:`,
|
`object-src 'self' blob:`,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
@@ -55,7 +55,7 @@ describe('auth-store logout redirects', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledWith('/api/auth/session?slot=0', { method: 'DELETE', keepalive: true });
|
expect(fetchMock).toHaveBeenCalledWith('/api/auth/session?slot=0', { method: 'DELETE', keepalive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks session expiry, preserves the current path, and redirects to login on refresh failure', async () => {
|
it('marks session expiry, preserves the current path, and redirects to login when the refresh is rejected (401)', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => {
|
const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => {
|
||||||
@@ -63,7 +63,7 @@ describe('auth-store logout redirects', () => {
|
|||||||
const method = init?.method ?? 'GET';
|
const method = init?.method ?? 'GET';
|
||||||
|
|
||||||
if (url === '/api/auth/token?slot=0' && method === 'PUT') {
|
if (url === '/api/auth/token?slot=0' && method === 'PUT') {
|
||||||
return { ok: false, json: async () => ({}) };
|
return { ok: false, status: 401, json: async () => ({}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url === '/api/auth/token?slot=0' && method === 'DELETE') {
|
if (url === '/api/auth/token?slot=0' && method === 'DELETE') {
|
||||||
@@ -94,4 +94,74 @@ describe('auth-store logout redirects', () => {
|
|||||||
expect(sessionStorage.getItem('redirect_after_login')).toBe('/en/calendar?view=day');
|
expect(sessionStorage.getItem('redirect_after_login')).toBe('/en/calendar?view=day');
|
||||||
expect(replaceSpy).toHaveBeenCalledWith('/en/login');
|
expect(replaceSpy).toHaveBeenCalledWith('/en/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the session and schedules a retry when the token endpoint is unavailable (5xx)', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = init?.method ?? 'GET';
|
||||||
|
|
||||||
|
if (url === '/api/auth/token?slot=0' && method === 'PUT') {
|
||||||
|
return { ok: false, status: 503, json: async () => ({}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected fetch call: ${method} ${url}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const replaceSpy = vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
|
||||||
|
|
||||||
|
useAuthStore.setState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
authMode: 'oauth',
|
||||||
|
activeAccountId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = await useAuthStore.getState().refreshAccessToken();
|
||||||
|
|
||||||
|
expect(token).toBeNull();
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
|
expect(sessionStorage.getItem('session_expired')).toBeNull();
|
||||||
|
expect(replaceSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// A retry is armed: advancing past the ~30 s window fires a second PUT.
|
||||||
|
await vi.advanceTimersByTimeAsync(31_000);
|
||||||
|
const refreshPuts = fetchMock.mock.calls.filter(
|
||||||
|
([input, init]) => String(input) === '/api/auth/token?slot=0' && init?.method === 'PUT',
|
||||||
|
);
|
||||||
|
expect(refreshPuts.length).toBe(2);
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the session when the refresh request fails with a network error', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
const method = init?.method ?? 'GET';
|
||||||
|
|
||||||
|
if (url === '/api/auth/token?slot=0' && method === 'PUT') {
|
||||||
|
throw new TypeError('Failed to fetch');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected fetch call: ${method} ${url}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const replaceSpy = vi.spyOn(browserNavigation, 'replaceWindowLocation').mockImplementation(() => {});
|
||||||
|
|
||||||
|
useAuthStore.setState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
authMode: 'oauth',
|
||||||
|
activeAccountId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = await useAuthStore.getState().refreshAccessToken();
|
||||||
|
|
||||||
|
expect(token).toBeNull();
|
||||||
|
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
||||||
|
expect(sessionStorage.getItem('session_expired')).toBeNull();
|
||||||
|
expect(replaceSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+63
-8
@@ -75,6 +75,32 @@ function isRateLimitError(error: unknown): error is RateLimitError {
|
|||||||
return error instanceof RateLimitError;
|
return error instanceof RateLimitError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An auth/session endpoint answered with a server-side error (5xx) - an
|
||||||
|
// outage, not a rejection of our credentials.
|
||||||
|
class TransientAuthError extends Error {
|
||||||
|
constructor(message: string, readonly status: number) {
|
||||||
|
super(`${message}: ${status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when a restore/refresh attempt failed because the server could not be
|
||||||
|
// reached (network error) or answered 5xx (restart, maintenance, proxy
|
||||||
|
// hiccup). Such failures must keep the account and its cookies - "stay signed
|
||||||
|
// in" has to survive downtime and offline spells. Only a definitive rejection
|
||||||
|
// (401/400) may evict. Mirrors the rate-limit carve-out (#104).
|
||||||
|
function isTransientAuthError(error: unknown): boolean {
|
||||||
|
if (error instanceof TransientAuthError) return true;
|
||||||
|
// fetch() rejects with TypeError when the network is unreachable.
|
||||||
|
if (error instanceof TypeError) return true;
|
||||||
|
// JMAPClient.connect()/refreshSession() embed the HTTP status in the
|
||||||
|
// message - a 5xx there is the server being down, not an auth failure.
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const m = error.message.match(/(?:Failed to get session|Session refresh failed): (\d{3})/);
|
||||||
|
if (m) return m[1].startsWith('5');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function getClientRateLimitState(client: IJMAPClient | null): Pick<AuthState, 'isRateLimited' | 'rateLimitUntil'> {
|
function getClientRateLimitState(client: IJMAPClient | null): Pick<AuthState, 'isRateLimited' | 'rateLimitUntil'> {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return { isRateLimited: false, rateLimitUntil: null };
|
return { isRateLimited: false, rateLimitUntil: null };
|
||||||
@@ -294,6 +320,10 @@ const clients = new Map<string, JMAPClient>();
|
|||||||
const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
const refreshPromises = new Map<string, Promise<string | null>>();
|
const refreshPromises = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
// Pseudo-expiry passed to scheduleRefresh when a refresh failed transiently:
|
||||||
|
// the "expiry - 60s" math below turns 90 into a retry in ~30 seconds.
|
||||||
|
const TOKEN_REFRESH_RETRY_SECONDS = 90;
|
||||||
|
|
||||||
function scheduleRefresh(expiresIn: number, refreshFn: () => Promise<string | null>, accountId?: string): void {
|
function scheduleRefresh(expiresIn: number, refreshFn: () => Promise<string | null>, accountId?: string): void {
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
const existing = refreshTimers.get(accountId);
|
const existing = refreshTimers.get(accountId);
|
||||||
@@ -948,9 +978,18 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const res = await apiFetch(`/api/auth/token?slot=${slot}`, { method: 'PUT' });
|
const res = await apiFetch(`/api/auth/token?slot=${slot}`, { method: 'PUT' });
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
notifyParent('sso:session-expired');
|
// Only a definitive 401 ends the session. Anything else (5xx
|
||||||
markSessionExpired();
|
// while the server restarts, proxy errors) is an outage - keep
|
||||||
get().logout();
|
// the session and retry shortly so "stay signed in" survives
|
||||||
|
// maintenance windows and offline spells.
|
||||||
|
if (res.status === 401) {
|
||||||
|
notifyParent('sso:session-expired');
|
||||||
|
markSessionExpired();
|
||||||
|
get().logout();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
debug.error(`Token refresh unavailable (${res.status}), retrying shortly`);
|
||||||
|
scheduleRefresh(TOKEN_REFRESH_RETRY_SECONDS, get().refreshAccessToken, accountId ?? undefined);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -975,10 +1014,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
scheduleRefresh(expires_in, get().refreshAccessToken, accountId ?? undefined);
|
scheduleRefresh(expires_in, get().refreshAccessToken, accountId ?? undefined);
|
||||||
return access_token;
|
return access_token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Token refresh failed:', error);
|
// Network failure (offline, Wi-Fi switch, server unreachable) -
|
||||||
notifyParent('sso:session-expired');
|
// not a rejection. Keep the session and retry shortly.
|
||||||
markSessionExpired();
|
debug.error('Token refresh failed, retrying shortly:', error);
|
||||||
get().logout();
|
scheduleRefresh(TOKEN_REFRESH_RETRY_SECONDS, get().refreshAccessToken, accountId ?? undefined);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
refreshPromise = null;
|
refreshPromise = null;
|
||||||
@@ -1374,6 +1413,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
scheduleRefresh(expires_in, get().refreshAccessToken, account.id);
|
scheduleRefresh(expires_in, get().refreshAccessToken, account.id);
|
||||||
await syncStalwartAuthContext(account.serverUrl, account.username, client.getAuthHeader(), account.cookieSlot);
|
await syncStalwartAuthContext(account.serverUrl, account.username, client.getAuthHeader(), account.cookieSlot);
|
||||||
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
|
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
|
||||||
|
} else if (res.status >= 500) {
|
||||||
|
throw new TransientAuthError('Token refresh failed', res.status);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Token refresh failed: ${res.status}`);
|
throw new Error(`Token refresh failed: ${res.status}`);
|
||||||
}
|
}
|
||||||
@@ -1387,6 +1428,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
clients.set(account.id, client);
|
clients.set(account.id, client);
|
||||||
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), account.cookieSlot);
|
await syncStalwartAuthContext(serverUrl, username, client.getAuthHeader(), account.cookieSlot);
|
||||||
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
|
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
|
||||||
|
} else if (res.status >= 500) {
|
||||||
|
throw new TransientAuthError('Session restore failed', res.status);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Session cookie missing: ${res.status}`);
|
throw new Error(`Session cookie missing: ${res.status}`);
|
||||||
}
|
}
|
||||||
@@ -1401,6 +1444,18 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Outage or offline - keep the account (and its cookies) so the
|
||||||
|
// session resumes once the server is reachable again. Same
|
||||||
|
// treatment as the rate-limit case above; only a definitive
|
||||||
|
// rejection below evicts.
|
||||||
|
if (isTransientAuthError(err)) {
|
||||||
|
accountStore.updateAccount(account.id, {
|
||||||
|
isConnected: false,
|
||||||
|
hasError: true,
|
||||||
|
errorMessage: 'Server unreachable',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Remove unrestorable accounts so the user is prompted to log in
|
// Remove unrestorable accounts so the user is prompted to log in
|
||||||
// again rather than seeing a stale error entry forever.
|
// again rather than seeing a stale error entry forever.
|
||||||
evictAccount(account.id);
|
evictAccount(account.id);
|
||||||
@@ -1627,7 +1682,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('Basic session restore failed:', error);
|
debug.error('Basic session restore failed:', error);
|
||||||
if (isRateLimitError(error)) {
|
if (isRateLimitError(error) || isTransientAuthError(error)) {
|
||||||
set({ isLoading: false, error: 'connection_failed', isRateLimited: false, rateLimitUntil: null });
|
set({ isLoading: false, error: 'connection_failed', isRateLimited: false, rateLimitUntil: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-18
@@ -945,7 +945,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
|
|
||||||
// When filtering by tag, omit the mailbox constraint so emails across
|
// When filtering by tag, omit the mailbox constraint so emails across
|
||||||
// all folders that carry the tag are returned.
|
// all folders that carry the tag are returned.
|
||||||
const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
|
const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter, true);
|
||||||
set({
|
set({
|
||||||
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
||||||
hasMoreEmails: result.hasMore,
|
hasMoreEmails: result.hasMore,
|
||||||
@@ -1108,7 +1108,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||||
|
|
||||||
// When filtering by tag, omit the mailbox constraint (same rationale as fetchEmails).
|
// When filtering by tag, omit the mailbox constraint (same rationale as fetchEmails).
|
||||||
result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
|
result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedMailbox === ALL_MAIL_MAILBOX_ID) {
|
if (selectedMailbox === ALL_MAIL_MAILBOX_ID) {
|
||||||
@@ -2278,10 +2278,44 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read' && isUnread;
|
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read' && isUnread;
|
||||||
await actionClient.markAsSpam(emailId, accountId, alsoMarkRead);
|
await actionClient.markAsSpam(emailId, accountId, alsoMarkRead);
|
||||||
|
|
||||||
set(state => ({
|
// Same junk resolution the client used for the move itself.
|
||||||
emails: state.emails.filter(e => e.id !== emailId),
|
const junkMailbox = mailboxes.find(m =>
|
||||||
selectedEmail: getNextSelectedEmail(state, emailId),
|
accountId ? (m.role === 'junk' && m.accountId === accountId) : (m.role === 'junk' && !m.isShared)
|
||||||
}));
|
);
|
||||||
|
// After marking read in the same request, the email arrives in junk as read.
|
||||||
|
const arrivesUnread = isUnread && !alsoMarkRead;
|
||||||
|
|
||||||
|
set(state => {
|
||||||
|
// Counter changes go to the email's own account list; source and junk
|
||||||
|
// live in the same account. (#281)
|
||||||
|
const mailboxPatch = applyMailboxCounterUpdate(state, email, (mailbox) => {
|
||||||
|
if (emailInMailbox(email, mailbox)) {
|
||||||
|
return {
|
||||||
|
...mailbox,
|
||||||
|
totalEmails: Math.max(0, mailbox.totalEmails - 1),
|
||||||
|
unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails,
|
||||||
|
totalThreads: Math.max(0, mailbox.totalThreads - 1),
|
||||||
|
unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (junkMailbox && mailbox.id === junkMailbox.id) {
|
||||||
|
return {
|
||||||
|
...mailbox,
|
||||||
|
totalEmails: mailbox.totalEmails + 1,
|
||||||
|
unreadEmails: arrivesUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
|
||||||
|
totalThreads: mailbox.totalThreads + 1,
|
||||||
|
unreadThreads: arrivesUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return mailbox;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
emails: state.emails.filter(e => e.id !== emailId),
|
||||||
|
selectedEmail: getNextSelectedEmail(state, emailId),
|
||||||
|
...mailboxPatch,
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to mark as spam:', error);
|
console.error('Failed to mark as spam:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -2340,6 +2374,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await undoClient.undoSpam(emailId, targetMailboxId, accountId);
|
await undoClient.undoSpam(emailId, targetMailboxId, accountId);
|
||||||
|
|
||||||
|
// Drop the restored mail from the current junk view and advance the open
|
||||||
|
// message to the next one, mirroring markAsSpam. Without this the viewer
|
||||||
|
// stays stuck on the mail that just left the folder.
|
||||||
|
set(state => ({
|
||||||
|
emails: state.emails.filter(e => e.id !== emailId),
|
||||||
|
selectedEmail: getNextSelectedEmail(state, emailId),
|
||||||
|
}));
|
||||||
|
|
||||||
// Refresh the view the user is actually looking at.
|
// Refresh the view the user is actually looking at.
|
||||||
if (get().isUnifiedView && get().crossView) {
|
if (get().isUnifiedView && get().crossView) {
|
||||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||||
@@ -2352,6 +2395,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
} else {
|
} else {
|
||||||
await get().fetchEmails(client, selectedMailbox);
|
await get().fetchEmails(client, selectedMailbox);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Folder counts don't move on their own without a reliable JMAP push
|
||||||
|
// (often absent on subpath/cross-origin deploys), so refresh them here so
|
||||||
|
// the junk folder's badge drops right away.
|
||||||
|
void get().fetchMailboxes(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to restore email:', error);
|
console.error('Failed to restore email:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -2375,11 +2423,52 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
await effectiveClient.markAsSpam(emailId, currentMailbox.accountId, markRead);
|
await effectiveClient.markAsSpam(emailId, currentMailbox.accountId, markRead);
|
||||||
}
|
}
|
||||||
|
|
||||||
set(state => ({
|
// Adjust counters and drop the emails from the current view. Junk is
|
||||||
emails: state.emails.filter(e => !emailIds.includes(e.id)),
|
// resolved the same way the client did for the move itself.
|
||||||
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
|
const affected = emails.filter(e => emailIds.includes(e.id));
|
||||||
selectedEmailIds: new Set(),
|
const junkMailbox = mailboxes.find(m =>
|
||||||
}));
|
currentMailbox.accountId
|
||||||
|
? (m.role === 'junk' && m.accountId === currentMailbox.accountId)
|
||||||
|
: (m.role === 'junk' && !m.isShared)
|
||||||
|
);
|
||||||
|
let junkTotal = 0;
|
||||||
|
let junkUnread = 0;
|
||||||
|
for (const e of affected) {
|
||||||
|
junkTotal += 1;
|
||||||
|
// After marking read in the same request, the email arrives in junk as read.
|
||||||
|
if (!e.keywords?.$seen && !alsoMarkRead) junkUnread += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(state => {
|
||||||
|
// Decrement source folders per the email's own account list (#281).
|
||||||
|
const patch = applyBatchMailboxCounterUpdate(state, affected, (mb, group) => {
|
||||||
|
let dTotal = 0;
|
||||||
|
let dUnread = 0;
|
||||||
|
for (const e of group as Email[]) {
|
||||||
|
if (emailInMailbox(e, mb)) {
|
||||||
|
dTotal--;
|
||||||
|
if (!e.keywords?.$seen) dUnread--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dTotal === 0 && dUnread === 0 ? mb : {
|
||||||
|
...mb,
|
||||||
|
totalEmails: Math.max(0, mb.totalEmails + dTotal),
|
||||||
|
unreadEmails: Math.max(0, mb.unreadEmails + dUnread),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// The junk folder (picked from the active/viewing list) gains them.
|
||||||
|
if (junkMailbox) {
|
||||||
|
patch.mailboxes = patch.mailboxes.map(mb => mb.id === junkMailbox.id
|
||||||
|
? { ...mb, totalEmails: mb.totalEmails + junkTotal, unreadEmails: mb.unreadEmails + junkUnread }
|
||||||
|
: mb);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
emails: state.emails.filter(e => !emailIds.includes(e.id)),
|
||||||
|
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
|
||||||
|
selectedEmailIds: new Set(),
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to batch mark as spam:', error);
|
console.error('Failed to batch mark as spam:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -2409,11 +2498,41 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
await effectiveClient.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId);
|
await effectiveClient.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
set(state => ({
|
// Adjust counters and drop the restored emails from the junk view.
|
||||||
emails: state.emails.filter(e => !emailIds.includes(e.id)),
|
const affected = get().emails.filter(e => emailIds.includes(e.id));
|
||||||
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
|
let unreadDelta = 0;
|
||||||
selectedEmailIds: new Set(),
|
for (const e of affected) {
|
||||||
}));
|
if (!e.keywords?.$seen) unreadDelta += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(state => {
|
||||||
|
// Decrement source folders per the email's own account list (#281).
|
||||||
|
const patch = applyBatchMailboxCounterUpdate(state, affected, (mb, group) => {
|
||||||
|
let dTotal = 0;
|
||||||
|
let dUnread = 0;
|
||||||
|
for (const e of group as Email[]) {
|
||||||
|
if (emailInMailbox(e, mb)) {
|
||||||
|
dTotal--;
|
||||||
|
if (!e.keywords?.$seen) dUnread--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dTotal === 0 && dUnread === 0 ? mb : {
|
||||||
|
...mb,
|
||||||
|
totalEmails: Math.max(0, mb.totalEmails + dTotal),
|
||||||
|
unreadEmails: Math.max(0, mb.unreadEmails + dUnread),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// The inbox (picked from the active/viewing list) gains them.
|
||||||
|
patch.mailboxes = patch.mailboxes.map(mb => mb.id === inboxMailbox.id
|
||||||
|
? { ...mb, totalEmails: mb.totalEmails + affected.length, unreadEmails: mb.unreadEmails + unreadDelta }
|
||||||
|
: mb);
|
||||||
|
return {
|
||||||
|
emails: state.emails.filter(e => !emailIds.includes(e.id)),
|
||||||
|
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
|
||||||
|
selectedEmailIds: new Set(),
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to batch restore emails:', error);
|
console.error('Failed to batch restore emails:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -2524,7 +2643,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
||||||
result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
|
result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
|
||||||
} else {
|
} else {
|
||||||
result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
|
result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0, undefined, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentEmails = get().emails;
|
const currentEmails = get().emails;
|
||||||
@@ -2534,7 +2653,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
// Without these guards the toast/sound also fires when sending,
|
// Without these guards the toast/sound also fires when sending,
|
||||||
// saving drafts, or moving/deleting the top message in any mailbox,
|
// saving drafts, or moving/deleting the top message in any mailbox,
|
||||||
// because all of those change the first-email id of the current view.
|
// because all of those change the first-email id of the current view.
|
||||||
const newFirst = result.emails[0];
|
// Pinned mails sit above the date order, so the newest mail is the
|
||||||
|
// first NON-pinned entry (a just-arrived mail cannot be pinned yet).
|
||||||
|
const newFirst = result.emails.find(e => !e.keywords?.['$pinned']) ?? result.emails[0];
|
||||||
if (
|
if (
|
||||||
newFirst &&
|
newFirst &&
|
||||||
mailbox?.role === 'inbox' &&
|
mailbox?.role === 'inbox' &&
|
||||||
|
|||||||
Reference in New Issue
Block a user