Feature: pin emails to the top of the folder list

Outlook-Web-style pinning: a context-menu Pin/Unpin action stores a
$pinned keyword on the message (plain IMAP-compatible flag, survives
other clients), and pinned mails stay at the top of the folder list
regardless of age, marked with a pin icon.

Ordering is done server-side via the hasKeyword sort comparator
(RFC 8621), applied consistently to the folder fetch, pagination and
the push-refresh so page windows stay stable. The client-side safety
sort in getEmails mirrors it, and sortThreadGroups keeps threads
containing a pinned mail on top so the client-side thread grouping
does not undo the order.

The new-mail notification in refreshCurrentMailbox now checks the
first non-pinned entry: with pinned mails on top, the newest mail is
no longer at index 0 and arrivals would never have notified.

The toggle reuses the color-tag pathway (routed keyword write for
unified views, in-place local patch), then refetches the first page
so the mail floats or sinks immediately. Search and unified views
keep their existing order.

Pin/Unpin strings are added to all 21 locales.
This commit is contained in:
dealerweb
2026-07-06 13:36:25 +02:00
committed by Linus Rath
parent d7a64fd9d6
commit d384c3b553
33 changed files with 194 additions and 39 deletions
+42
View File
@@ -1652,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) => {
if (!client) return;
@@ -3042,6 +3081,9 @@ export default function Home() {
await toggleStar(client, email.id);
}
}}
onTogglePinned={async (email) => {
await handleTogglePinned(email);
}}
onDelete={async (email) => {
await handleDelete(email);
}}
+14
View File
@@ -17,6 +17,8 @@ import {
Mail,
MailOpen,
Star,
Pin,
PinOff,
Trash2,
Archive,
FolderInput,
@@ -59,6 +61,7 @@ interface EmailContextMenuProps {
onForward?: () => void;
onMarkAsRead?: (read: boolean) => void;
onToggleStar?: () => void;
onTogglePinned?: () => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
@@ -126,6 +129,7 @@ export function EmailContextMenu({
onForward,
onMarkAsRead,
onToggleStar,
onTogglePinned,
onDelete,
onArchive,
onSetColorTag,
@@ -149,6 +153,7 @@ export function EmailContextMenu({
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isDraft = email.keywords?.['$draft'] === true;
const currentColors = getCurrentColors(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
@@ -352,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 */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
+6 -1
View File
@@ -6,7 +6,7 @@ import { formatDate, stripInvisibleLeading } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
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 { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-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 isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isImportant = email.keywords?.["$important"];
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
@@ -217,6 +218,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
</div>
</div>
<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" />}
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
{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"}
</span>
<div className="flex items-center gap-1.5">
{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" />
)}
+3
View File
@@ -35,6 +35,7 @@ interface EmailListProps {
onForward?: (email: Email) => void;
onMarkAsRead?: (email: Email, read: boolean) => void;
onToggleStar?: (email: Email) => void;
onTogglePinned?: (email: Email) => void;
onDelete?: (email: Email) => void;
onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
@@ -64,6 +65,7 @@ export function EmailList({
onForward,
onMarkAsRead,
onToggleStar,
onTogglePinned,
onDelete,
onArchive,
onSetColorTag,
@@ -551,6 +553,7 @@ export function EmailList({
onForward={() => onForward?.(contextMenu.data!)}
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
onDelete={() => onDelete?.(contextMenu.data!)}
onArchive={() => onArchive?.(contextMenu.data!)}
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
+11 -2
View File
@@ -5,7 +5,7 @@ import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
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 { useUIStore } from "@/stores/ui-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 isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
@@ -264,6 +265,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<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" />}
{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" />}
@@ -316,6 +318,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{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" />
)}
@@ -443,7 +448,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
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.
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
@@ -723,6 +728,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
</div>
<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" />}
{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" />}
@@ -787,6 +793,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{emailCount}
</span>
<div className="flex items-center gap-1.5">
{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" />
)}
+37 -25
View File
@@ -116,37 +116,47 @@ describe('groupEmailsByThread', () => {
});
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', () => {
const groups: ThreadGroup[] = [
{
threadId: 'old',
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 groups = [
makeGroup('old', '2024-01-01T00:00:00Z'),
makeGroup('new', '2024-06-01T00:00:00Z'),
];
const sorted = sortThreadGroups(groups);
expect(sorted[0].threadId).toBe('new');
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', () => {
@@ -188,6 +198,7 @@ describe('mergeThreadEmails', () => {
participantNames: ['Alice'],
hasUnread: false,
hasStarred: false,
hasPinned: false,
hasAttachment: false,
hasAnswered: false,
hasForwarded: false,
@@ -210,6 +221,7 @@ describe('mergeThreadEmails', () => {
participantNames: ['Alice'],
hasUnread: false,
hasStarred: false,
hasPinned: false,
hasAttachment: false,
hasAnswered: false,
hasForwarded: false,
+6 -2
View File
@@ -151,12 +151,16 @@ export class DemoJMAPClient implements IJMAPClient {
// ── 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;
if (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 emails = filtered.slice(position, position + limit);
return { emails, hasMore: position + limit < total, total };
+3 -1
View File
@@ -78,7 +78,9 @@ export interface IJMAPClient {
deleteMailbox(mailboxId: string): Promise<void>;
// ── 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[]>;
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
+13 -2
View File
@@ -1053,7 +1053,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 {
const targetAccountId = accountId || this.accountId;
const filter: { inMailbox?: string; hasKeyword?: string } = {};
@@ -1063,12 +1063,20 @@ export class JMAPClient implements IJMAPClient {
if (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([
["Email/query", {
accountId: targetAccountId,
filter,
sort: [{ property: "receivedAt", isAscending: false }],
sort,
limit,
position,
calculateTotal: true,
@@ -1087,7 +1095,10 @@ export class JMAPClient implements IJMAPClient {
const emails = (getResponse.list || []) as Email[];
// Sort client-side as safety net - some servers may not honour
// 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) =>
pinRank(b) - pinRank(a) ||
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
const total = queryResponse?.total || 0;
+1
View File
@@ -217,6 +217,7 @@ export interface ThreadGroup {
participantNames: string[];// Unique participant names
hasUnread: boolean; // Any unread 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
hasAnswered: boolean; // Any email has been replied to
hasForwarded: boolean; // Any email has been forwarded
+10 -2
View File
@@ -44,9 +44,10 @@ export function groupEmailsByThread(
// Collect unique participant names from all emails in thread
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 hasStarred = sortedEmails.some(e => e.keywords?.$flagged);
const hasPinned = sortedEmails.some(e => e.keywords?.['$pinned']);
const hasAttachment = sortedEmails.some(e => e.hasAttachment);
const hasAnswered = sortedEmails.some(e => e.keywords?.$answered);
const hasForwarded = sortedEmails.some(e => e.keywords?.$forwarded);
@@ -58,6 +59,7 @@ export function groupEmailsByThread(
participantNames,
hasUnread,
hasStarred,
hasPinned,
hasAttachment,
hasAnswered,
hasForwarded,
@@ -70,10 +72,14 @@ export function groupEmailsByThread(
/**
* 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[] {
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 hasUnread = mergedEmails.some(e => !e.keywords?.$seen);
const hasStarred = mergedEmails.some(e => e.keywords?.$flagged);
const hasPinned = mergedEmails.some(e => e.keywords?.['$pinned']);
const hasAttachment = mergedEmails.some(e => e.hasAttachment);
const hasAnswered = mergedEmails.some(e => e.keywords?.$answered);
const hasForwarded = mergedEmails.some(e => e.keywords?.$forwarded);
@@ -147,6 +154,7 @@ export function mergeThreadEmails(
participantNames,
hasUnread,
hasStarred,
hasPinned,
hasAttachment,
hasAnswered,
hasForwarded,
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Označit jako nepřečtené",
"star": "Označit hvězdičkou",
"unstar": "Odebrat hvězdičku",
"pin": "Připnout",
"unpin": "Odepnout",
"move_to": "Přesunout do...",
"archive": "Archivovat",
"delete": "Odstranit",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Markér som ulæst",
"star": "Stjernemarkér",
"unstar": "Fjern stjerne",
"pin": "Fastgør",
"unpin": "Frigør",
"move_to": "Flyt til...",
"archive": "Arkivér",
"delete": "Slet",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Als ungelesen markieren",
"star": "Stern hinzufügen",
"unstar": "Stern entfernen",
"pin": "Anheften",
"unpin": "Lösen",
"move_to": "Verschieben nach...",
"archive": "Archivieren",
"delete": "Löschen",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Mark as Unread",
"star": "Star",
"unstar": "Unstar",
"pin": "Pin",
"unpin": "Unpin",
"move_to": "Move to...",
"archive": "Archive",
"delete": "Delete",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Marcar como No Leído",
"star": "Destacar",
"unstar": "Quitar Destacado",
"pin": "Anclar",
"unpin": "Desanclar",
"move_to": "Mover a...",
"archive": "Archivar",
"delete": "Eliminar",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "علامت‌گذاری خوانده نشده",
"star": "ستاره‌دار",
"unstar": "حذف ستاره",
"pin": "سنجاق کردن",
"unpin": "برداشتن سنجاق",
"move_to": "انتقال به...",
"archive": "بایگانی",
"delete": "حذف",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Marquer comme non lu",
"star": "Marquer comme favori",
"unstar": "Retirer des favoris",
"pin": "Épingler",
"unpin": "Désépingler",
"move_to": "Déplacer vers...",
"archive": "Archiver",
"delete": "Supprimer",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Olvasatlannak jelölés",
"star": "Csillagozás",
"unstar": "Csillagozás megszüntetése",
"pin": "Rögzítés",
"unpin": "Rögzítés feloldása",
"move_to": "Áthelyezés ide...",
"archive": "Archiválás",
"delete": "Törlés",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Segna come non letto",
"star": "Aggiungi stella",
"unstar": "Rimuovi stella",
"pin": "Fissa",
"unpin": "Non fissare più",
"move_to": "Sposta in...",
"archive": "Archivia",
"delete": "Elimina",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "未読にする",
"star": "スターを付ける",
"unstar": "スターを外す",
"pin": "ピン留め",
"unpin": "ピン留めを外す",
"move_to": "移動...",
"archive": "アーカイブ",
"delete": "削除",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "읽지 않은 상태로 표시",
"star": "별표 달기",
"unstar": "별표 해제",
"pin": "고정",
"unpin": "고정 해제",
"move_to": "이동...",
"archive": "보관",
"delete": "삭제",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Atzīmēt kā nelasītu",
"star": "Pievienot zvaigznīti",
"unstar": "Noņemt zvaigznīti",
"pin": "Piespraust",
"unpin": "Atspraust",
"move_to": "Pārvietot uz...",
"archive": "Arhivēt",
"delete": "Dzēst",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Markeren als ongelezen",
"star": "Ster toevoegen",
"unstar": "Ster verwijderen",
"pin": "Vastmaken",
"unpin": "Losmaken",
"move_to": "Verplaatsen naar...",
"archive": "Archiveren",
"delete": "Verwijderen",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Oznacz jako nieprzeczytane",
"star": "Oznacz gwiazdką",
"unstar": "Usuń gwiazdkę",
"pin": "Przypnij",
"unpin": "Odepnij",
"move_to": "Przenieś do...",
"archive": "Archiwizuj",
"delete": "Usuń",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Marcar como Não Lido",
"star": "Adicionar Estrela",
"unstar": "Remover Estrela",
"pin": "Fixar",
"unpin": "Desafixar",
"move_to": "Mover para...",
"archive": "Arquivar",
"delete": "Excluir",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Marcați ca necitit",
"star": "Stea",
"unstar": "Anulează marcarea cu stea",
"pin": "Fixează",
"unpin": "Anulează fixarea",
"move_to": "Mergi la...",
"archive": "Arhivează",
"delete": "Șterge",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Отметить как непрочитанное",
"star": "Добавить звёздочку",
"unstar": "Убрать звёздочку",
"pin": "Закрепить",
"unpin": "Открепить",
"move_to": "Переместить в...",
"archive": "В архив",
"delete": "Удалить",
+2
View File
@@ -1943,6 +1943,8 @@
"mark_unread": "Označiť ako neprečítané",
"star": "Hviezdička",
"unstar": "Odstrániť hviezdičku",
"pin": "Pripnúť",
"unpin": "Odopnúť",
"move_to": "Presunúť do...",
"archive": "Archivovať",
"delete": "Odstrániť",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Okunmadı Olarak İşaretle",
"star": "Yıldız Ekle",
"unstar": "Yıldızı Kaldır",
"pin": "Sabitle",
"unpin": "Sabitlemeyi kaldır",
"move_to": "Şuraya taşı...",
"archive": "Arşivle",
"delete": "Sil",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "Позначити як непрочитане",
"star": "зірка",
"unstar": "Зняти зірочку",
"pin": "Закріпити",
"unpin": "Відкріпити",
"move_to": "Перейти до...",
"archive": "Архів",
"delete": "Видалити",
+2
View File
@@ -1954,6 +1954,8 @@
"mark_unread": "标记为未读",
"star": "加星标",
"unstar": "取消星标",
"pin": "固定",
"unpin": "取消固定",
"move_to": "移动到…",
"archive": "归档",
"delete": "删除",
+6 -4
View File
@@ -945,7 +945,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// When filtering by tag, omit the mailbox constraint so emails across
// 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({
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore,
@@ -1108,7 +1108,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// 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) {
@@ -2643,7 +2643,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
} else {
result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0, undefined, true);
}
const currentEmails = get().emails;
@@ -2653,7 +2653,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Without these guards the toast/sound also fires when sending,
// saving drafts, or moving/deleting the top message in any mailbox,
// 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 (
newFirst &&
mailbox?.role === 'inbox' &&