Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions .specify/memory/constitution.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,17 @@ Stormbox-specific backend.
an asynchronous push is not sufficient.
- Ordinary delete moves a message to Trash. Permanent destroy is
reserved for messages already in Trash or explicit destroy flows.
- Bulk applies shall batch SQL and coalesce per-row UI refreshes into
one paint.
- Mail operations (triage and message actions) shall support both a
single message and a multi-selected batch; the single-message action
is the N=1 case of the batched path, not a separate implementation.
- A batch shall be a real batch at every layer, not a per-item loop:
- Protocol: dispatch the chunk in as few calls as the server API
allows — a single multi-object request (e.g. JMAP `Email/set` /
`ContactCard/set` with multiple ids or a creation map, or query/get
back-references), resolving shared prerequisites once rather than
repeating the round trip per item.
- Storage: batch the chunk's SQL writes.
- UI: coalesce the per-row refreshes into a single paint.

### V. Incremental Sync

Expand Down Expand Up @@ -134,4 +143,12 @@ architectural constraints. Feature specs, plans, and tasks must call
out any conflict before implementation begins. Amendments require an
update to this file with a brief reason.

**Version**: 1.1.0 | **Ratified**: 2026-05-21 | **Last Amended**: 2026-05-22
**Version**: 1.3.0 | **Ratified**: 2026-05-21 | **Last Amended**: 2026-06-15

<!-- 1.2.0: Mutation Pipeline (IV) now requires every mail operation to
support both single and batched messages, with the single action as the
N=1 case of the batched path.
1.3.0: Mutation Pipeline (IV) now requires a batch to be a real batch at
every layer — protocol (a single multi-object request per chunk, not a
per-item loop), storage (batched SQL), and UI (coalesced into one
paint) — folding in the former standalone bulk-SQL/UI bullet. -->
2 changes: 1 addition & 1 deletion specs/001-mvp-scope/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ capability.
| R-3.13 🟧 Planned | The system shall provide an undo/redo queue for message triage operations including archive, move, delete (to Trash), and mark read/unread. The user shall be able to reverse the most recent action via Ctrl/Cmd+Z and reapply it via Ctrl/Cmd+Shift+Z (or Ctrl/Cmd+Y), matching Thunderbird-compatible behavior. The queue shall preserve a bounded history of recent operations within the active session, group bulk actions on multiple messages into a single undoable entry, and surface a transient confirmation (e.g. toast) after each action with an undo affordance. Permanent destroy (Shift+Delete and Trash purge) shall remain non-undoable. The queue shall be cleared on sign-out and is not required to survive page reloads. |
| R-3.14 🟩 Done | When the user issues a move or delete (move-to-Trash or permanent destroy) covering more than a configurable batch size (default 200 messages), the system shall split the dispatch into sequential JMAP `Email/set` chunks no larger than that batch size, display a modal progress indicator that names the operation and shows messages-completed / total, and block other user input until the operation finishes. Each chunk shall be its own `pending_mutations` row so the outbox apply step still keeps the local cache authoritative per chunk. On a chunk failure the system shall stop further chunks, leave already-succeeded chunks applied, surface an error that distinguishes the partial outcome (e.g. "Could not move message (requestTooLarge) (400 of 536 succeeded).") derived from the JMAP method-level error type when the server returns one, and clear the progress indicator. The batch size shall be a single source-level constant so it can be tuned without schema or API changes. |
| R-3.15 🟩 Done | The active row shall be tracked as a single stable message-id cursor shared by every navigation path — arrow keys on the focused list, the Thunderbird shortcuts of R-3.7, row clicks, and the automatic preview advance of R-3.11. The cursor shall coincide with the previewed message on plain navigation and clicks; during a Shift+Arrow range extension (R-3.6) the cursor shall advance to the range's leading edge while the previewed message stays put. When the cursor moves, the system shall scroll the virtualized message list (R-2.2) so the cursor row is brought into view with minimal movement (no scroll when it is already fully visible), because an off-screen row in the virtualized list is not present in the DOM and cannot be revealed by element-level scrolling. The message list shall expose listbox semantics for assistive technology: a listbox container, option rows carrying their selected state, and an active-descendant reference that tracks the cursor row kept in view. |
| R-3.16 🟩 Done | When the user opens a message in the Junk folder, the system shall offer a "Not junk" action that whitelists the sender and rescues the message. Whitelisting shall add the sender's address to a dedicated "Trusted senders" address book as a `ContactCard` so the server's contact trust delivers future authenticated mail from that address to the Inbox; rescuing shall clear the `$junk` keyword, set `$notjunk`, and move the message from Junk to the Inbox. The action shall be offered only from the Junk folder. The system shall confirm success only when the sender was actually trusted; when the message moved but the trust write did not apply, the system shall surface that partial outcome rather than reporting that the sender was whitelisted. |
| R-3.16 🟩 Done | When the user opens a message in the Junk folder, the system shall offer a "Not junk" action that whitelists the sender and rescues the message; the same action shall be offered for a multi-selected batch in the Junk folder, whitelisting each unique sender once (de-duplicated by address) and rescuing every selected message. Whitelisting shall add each sender's address to a dedicated "Trusted senders" address book as a `ContactCard` so the server's contact trust delivers future authenticated mail from that address to the Inbox; rescuing shall clear the `$junk` keyword, set `$notjunk`, and move the affected messages from Junk to the Inbox. The action shall be offered only from the Junk folder. The system shall confirm success only when the senders were actually trusted; when the messages moved but the trust write did not apply, the system shall surface that partial outcome rather than reporting that the senders were whitelisted. |

### 4. Compose and send

Expand Down
30 changes: 30 additions & 0 deletions src/components/MessageView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,23 @@ async function bulkDelete() {
}
}

// Bulk "Not junk": whitelist every selected message's sender and move
// the batch to the Inbox. Junk-folder only, gated in the template.
const bulkWhitelisting = ref(false);

async function bulkWhitelist() {
const ids = [...mailStore.selectedIds];
if (ids.length === 0 || bulkWhitelisting.value) return;
bulkWhitelisting.value = true;
try {
await mailStore.whitelistSenders(ids);
} catch (err) {
console.warn('[message-view] bulk whitelist failed', err?.message ?? err);
} finally {
bulkWhitelisting.value = false;
}
}

function clearBulkSelection() {
mailStore.clearSelection();
}
Expand All @@ -563,6 +580,19 @@ function closeMessageView() {
{{ selectionCount }} {{ selectionCount === 1 ? 'message' : 'messages' }} selected
</h2>
<div class="message-view__bulk-actions">
<!-- Contextual: only in the Junk folder. Leads the group as a
labeled accent button, set apart from the icon buttons. -->
<button
v-if="isInJunkFolder"
class="message-view__action message-view__action--whitelist"
type="button"
:disabled="bulkWhitelisting"
@click="bulkWhitelist"
title="Whitelist senders and move to Inbox"
aria-label="Not junk — whitelist senders and move the selected messages to Inbox"
>
<span class="message-view__whitelist-label">Not junk</span>
</button>
<button class="message-view__action" type="button" @click="bulkArchive" title="Archive" aria-label="Archive">
<span class="message-view__toolbar-icon message-view__toolbar-icon--folder" aria-hidden="true" v-html="archiveIcon" />
</button>
Expand Down
173 changes: 118 additions & 55 deletions src/stores/mail-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1356,84 +1356,146 @@ export const useMailStore = defineStore('mail', () => {
}

/**
* Whitelist the sender of a Junk message (Strategy C from
* whitelist-in-webmail-notes.md):
* Whitelist the senders of one or more Junk messages and rescue the
* messages (Strategy C from whitelist-in-webmail-notes.md):
*
* 1. Trust the sender for future mail — queue a whitelistSender
* mutation that adds a ContactCard in the "Trusted senders"
* address book; Stalwart's trustContacts / card_is_ham then
* delivers future authenticated mail from that address to the
* Inbox.
* 2. Rescue the current message — remove $junk / add $notjunk
* (optimistic + queued setKeywords) and move Junk → Inbox, since
* contact trust only applies at ingest time for future mail.
* 1. Trust the senders for future mail — queue one whitelistSender
* mutation carrying the unique sender addresses; the outbox adds a
* ContactCard per address in the "Trusted senders" address book so
* Stalwart's trustContacts / card_is_ham delivers future
* authenticated mail from them to the Inbox, and reconciles the
* contacts cache once.
* 2. Rescue the messages — remove $junk / add $notjunk (optimistic +
* one queued setKeywords for the batch) and move Junk → Inbox,
* since contact trust only applies at ingest time for future mail.
*
* Only meaningful from the Junk folder; the UI gates the button on
* the junk role. Surfaces a transient success notice when the sender
* was trusted and the message moved, or an error when the message
* moved but the trust write did not apply.
* A message whose From header yields no address is still rescued; its
* sender is just skipped for trust. Only meaningful from the Junk
* folder; the UI gates the action on the junk role. Surfaces a
* transient success notice when the senders were trusted and the
* messages moved, or an error when the messages moved but the trust
* write did not apply.
*/
async function whitelistSender(messageId: number) {
async function whitelistSenders(ids: number[]): Promise<MoveResult> {
if (!repo || authStore.accountId == null) return { succeeded: 0, failed: 0, skipped: 0 };
const row = messages.value.find((m) => m?.id === messageId);
if (!row) return { succeeded: 0, failed: 0, skipped: 0 };
const sender = parseSender(row.from_text);
if (!sender) {
error.value = 'Could not determine the sender to whitelist.';
return { succeeded: 0, failed: 0, skipped: 0 };
}
const messageIds = normalizeMessageIds(ids);
if (messageIds.length === 0) return { succeeded: 0, failed: 0, skipped: 0 };
const target = inbox.value;
if (!target?.id) {
error.value = 'No Inbox folder is configured.';
return { succeeded: 0, failed: 0, skipped: 0 };
}

// 1) Trust the sender going forward. Run the mutation (rather than
// fire-and-forget) so the confirmation can reflect whether the
// trust write actually applied — the whole point of the action is
// that future mail is trusted, so we must not claim success when
// only the message move happened.
const trustMutation = await repo.insertPendingMutation({
accountId: authStore.accountId,
mutationType: MUTATION_TYPE.WHITELIST_SENDER,
targetMessageId: messageId,
requestJson: JSON.stringify({ email: sender.email, name: sender.name }),
// Gather the live rows and the unique senders to trust (deduped by
// address, case-insensitively). Rows with an unparseable From are
// still rescued below; they just contribute no trusted sender.
const rows: CachedRow[] = [];
const sendersByEmail = new Map<string, { name: string | null; email: string }>();
for (const id of messageIds) {
const row = messages.value.find((m) => m?.id === id);
if (!row) continue;
rows.push(row);
const sender = parseSender(row.from_text);
if (sender && !sendersByEmail.has(sender.email.toLowerCase())) {
sendersByEmail.set(sender.email.toLowerCase(), sender);
}
}
if (rows.length === 0) return { succeeded: 0, failed: 0, skipped: messageIds.length };
const rescueIds = rows.map((r) => r.id);
const senders = [...sendersByEmail.values()];

// 1) Trust every unique sender in a single mutation, run it, and
// capture whether the trust write applied — the whole point of the
// action is that future mail is trusted, so we must not claim
// success when only the message move happened.
let trusted = true;
if (senders.length > 0) {
const trustMutation = await repo.insertPendingMutation({
accountId: authStore.accountId,
mutationType: MUTATION_TYPE.WHITELIST_SENDER,
targetMessageId: null,
requestJson: JSON.stringify({ senders }),
});
const trustResult = typeof repo.runMutation === 'function' && trustMutation?.id != null
? await repo.runMutation(authStore.accountId, trustMutation.id)
: await repo.drainOutbox(authStore.accountId);
// A run that attempted nothing is not a success; mirror runChunkedMutation.
trusted = (trustResult?.failed ?? 0) === 0
&& ((trustResult?.attempted ?? 0) > 0 || (trustResult?.succeeded ?? 0) > 0);
}

// 2a) Rescue the selected messages' spam keywords: one optimistic
// transaction plus one queued setKeywords for the whole batch.
const optimisticItems = rows.map((row) => {
const keywordsJson = JSON.parse(row.keywords_json ?? '{}');
delete keywordsJson.$junk;
keywordsJson.$notjunk = true;
return {
messageId: row.id,
keywords: Object.keys(keywordsJson),
keywordsJson: JSON.stringify(keywordsJson),
};
});
const trustResult = typeof repo.runMutation === 'function' && trustMutation?.id != null
? await repo.runMutation(authStore.accountId, trustMutation.id)
: await repo.drainOutbox(authStore.accountId);
// A run that attempted nothing (e.g. the row never reached a
// runnable state) is not a success; mirror runChunkedMutation.
const trusted = (trustResult?.failed ?? 0) === 0
&& ((trustResult?.attempted ?? 0) > 0 || (trustResult?.succeeded ?? 0) > 0);

// 2a) Rescue the current message's spam keywords (optimistic + queued).
const keywordsJson = JSON.parse(row.keywords_json ?? '{}');
delete keywordsJson.$junk;
keywordsJson.$notjunk = true;
await repo.replaceMessageKeywords(messageId, Object.keys(keywordsJson), JSON.stringify(keywordsJson));
if (typeof repo.replaceMessageKeywordsMany === 'function') {
await repo.replaceMessageKeywordsMany(optimisticItems);
} else {
for (const item of optimisticItems) {
await repo.replaceMessageKeywords(item.messageId, item.keywords, item.keywordsJson);
}
}
await repo.insertPendingMutation({
accountId: authStore.accountId,
mutationType: MUTATION_TYPE.SET_KEYWORDS,
targetMessageId: messageId,
requestJson: JSON.stringify({ add: ['$notjunk'], remove: ['$junk'] }),
targetMessageId: rescueIds.length === 1 ? rescueIds[0] : null,
requestJson: JSON.stringify({ messageIds: rescueIds, add: ['$notjunk'], remove: ['$junk'] }),
});

// 2b) Move it out of Junk into the Inbox (the visible effect).
const result = await moveMessages([messageId], target.id);
// 2b) Move them all out of Junk into the Inbox (the visible effect).
const result = await moveMessages(rescueIds, target.id);
if (result.succeeded > 0) {
const who = sender.name ?? sender.email;
if (trusted) {
setNotice(`Whitelisted ${who} — moved to Inbox`);
const movedPhrase = result.succeeded === 1
? 'moved to Inbox'
: `moved ${result.succeeded} messages to Inbox`;
if (senders.length === 0) {
setNotice(`Moved ${result.succeeded} ${result.succeeded === 1 ? 'message' : 'messages'} to Inbox`);
} else if (trusted) {
const who = senders.length === 1
? (senders[0].name ?? senders[0].email)
: `${senders.length} senders`;
setNotice(`Whitelisted ${who} — ${movedPhrase}`);
} else {
// The message still moved, but the trust write did not apply —
// don't tell the user the sender was whitelisted.
error.value = `Moved to Inbox, but ${who} could not be added to your trusted contacts — future mail from them may still be treated as junk.`;
// Messages moved, but the trust write did not apply — don't claim
// the senders were whitelisted.
const what = result.succeeded === 1
? 'Moved to Inbox'
: `Moved ${result.succeeded} messages to Inbox`;
const who = senders.length === 1
? (senders[0].name ?? senders[0].email)
: 'the senders';
error.value = `${what}, but ${who} could not be added to your trusted contacts — future mail from them may still be treated as junk.`;
}
}
return result;
}

/**
* Whitelist the sender of a single Junk message and rescue it to the
* Inbox. Strict about the sender: when the From header yields no
* address there is nothing to whitelist, so it reports that rather than
* silently moving the message. Shares the batch work with
* whitelistSenders.
*/
async function whitelistSender(messageId: number): Promise<MoveResult> {
if (!repo || authStore.accountId == null) return { succeeded: 0, failed: 0, skipped: 0 };
const row = messages.value.find((m) => m?.id === messageId);
if (!row) return { succeeded: 0, failed: 0, skipped: 0 };
if (!parseSender(row.from_text)) {
error.value = 'Could not determine the sender to whitelist.';
return { succeeded: 0, failed: 0, skipped: 0 };
}
return whitelistSenders([messageId]);
}

/**
* Delete one or more messages. Single-row delete from the open
* message and multi-select bulk delete go through the same path,
Expand Down Expand Up @@ -2149,6 +2211,7 @@ export const useMailStore = defineStore('mail', () => {
moveMessages,
archiveMessages,
whitelistSender,
whitelistSenders,
canMoveToFolder,
clearSelection,
refresh,
Expand Down
Loading
Loading