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);
}}