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
+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' &&