mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-05 08:02:14 +00:00
fix(unibox): make Mark as unread stick, and let it reach a sent-only conversation
useMarkSeen invalidated every ["unibox"] query after an unread, including the body queries ["unibox", "email", id]. A body GET marks the message read server side (GetByID), so the still-mounted reader undid the unread. Bodies never change once synced, so the unread path now skips them. MarkSeenByThreads skipped every sent copy, so a conversation with no received message could not be marked unread at all. It now falls back to the newest sent copy when nothing was received. Received mail still wins, drafts never turn unread, and explicit ids and folder sweeps keep refusing sent and draft copies. Herkunft: [Claude] Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
50d50d0fbc
commit
c433a483ec
@@ -228,7 +228,7 @@ A source's `column_mapping` is validated when the source is written, not on its
|
||||
|
||||
`PATCH /unibox/folder` re-files into `inbox`, `archive` or `trash`. Name the messages with `email_ids`, the conversations with `thread_ids`, or both; up to 500 of each. Prefer `thread_ids` when you have one: filing part of a conversation leaves it listed, because a thread shows wherever any message of it still sits. The move is then relayed to each mailbox with `relay_folder_moves` on (the default): the provider's copy is archived, moved to its trash, or put back in its inbox, after the response and best-effort, and the next sync will not undo a filing either way, because the provider's placement is tracked separately and followed only when the provider itself moves the message. `sent`, `drafts` and `spam` are placements a provider reaches rather than somewhere a person files mail, so they are rejected with a `400`. See [filing a conversation](/guides/unibox/#filing-a-conversation).
|
||||
|
||||
`PATCH /unibox/seen` takes the same two forms: `email_ids` for individual messages, `thread_ids` for whole conversations. `folder` sweeps one folder instead and cannot be combined with either. Unread by conversation marks only its newest received message, and a sent or draft copy is never marked unread.
|
||||
`PATCH /unibox/seen` takes the same two forms: `email_ids` for individual messages, `thread_ids` for whole conversations. `folder` sweeps one folder instead and cannot be combined with either. Unread by conversation marks only its newest received message (preferring one outside spam and trash), or its newest sent copy if it has no received message; otherwise sent copies stay read. Drafts are never marked unread. Explicit `email_ids` and `folder` sweeps never mark sent or draft copies unread.
|
||||
|
||||
`GET /unibox` lists every working folder by default. Spam, trash and archive stay out, so a conversation you file leaves every view rather than only the Inbox folder; pass `include_archived=true` for the All mail behaviour, or `folder=archive` to read the folder itself.
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ Returns the resulting label set in a `data` array.
|
||||
|
||||
Marks messages as read or unread, org-wide: an explicit batch of up to 500 message ids, up to 500 conversations, or a whole canonical folder at once. `email_ids` and `thread_ids` can be sent together; `folder` cannot be combined with either and returns `400`. Auth: **Scope** `WRITE_UNIBOX` · **Org permission** `access_unibox`.
|
||||
|
||||
Marking a conversation read reads every message in it. Marking one unread works like Gmail: only its newest received message turns unread (one outside spam and trash when there is one), so the conversation shows as unread without your own replies doing the same. A sent or draft copy is never marked unread, whichever field names it.
|
||||
Marking a conversation read reads every message in it. Marking one unread marks only its newest received message unread (one outside spam and trash when there is one). If it has no received message, its newest sent copy turns unread instead; otherwise sent copies stay read. Drafts are never marked unread. Explicit `email_ids` and `folder` sweeps never mark sent or draft copies unread.
|
||||
|
||||
The change is also carried out to the mailbox itself, so a message marked read here stops showing as unread in Gmail, Outlook or an IMAP mailbox. Opening a message with `GET /unibox/:id` marks it read the same way, relay included. Only messages whose state actually changed are relayed, and the relay is best-effort: it happens after the response, on the worker holding the mailbox, and a mailbox that is unplaced at that moment keeps its own read state until something changes it again. [Filing a conversation](/guides/unibox/#filing-in-the-mailbox-too) is relayed the same way.
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ Labels mark conversations and contacts. Mailbox tags mark the mailboxes themselv
|
||||
|
||||
A conversation is unread when any message inside is unseen, marked by a blue dot in the margin, bolder text, and a blue timestamp. Opening it marks its messages seen and updates the counts, and so does **Mark as read** on the row or on a selection, without opening anything.
|
||||
|
||||
**Mark as unread** works like it does in Gmail: only the newest message you received in the conversation turns unread, so the conversation is unread again wherever that message sits. Your own sent replies are never marked unread, so the conversation does not light up in Sent, and Gmail does not label your sent copy unread either. Marking it unread from the open conversation closes it, because the reader marks whatever it shows as read.
|
||||
**Mark as unread** marks only the newest message you received in the conversation unread, preferring one outside Spam and Trash. If the conversation has no received message, its newest sent copy turns unread instead. Otherwise your own sent copies stay read. Drafts are never marked unread, and marking individual message ids or sweeping a folder never marks sent or draft copies unread. Marking it unread from the open conversation closes it, because the reader marks whatever it shows as read.
|
||||
|
||||
Read state travels both ways. A message arriving already read is stored that way, and reading a conversation here marks it read in the mailbox itself, so Gmail, Outlook and any IMAP mailbox stop showing it bold too. **Mark as unread** does the same in reverse, and **Mark all as read** on a folder carries the whole sweep across. Only a real change travels: opening a conversation you have already read costs nothing at the provider.
|
||||
|
||||
@@ -163,7 +163,7 @@ The thread header carries three filing actions, on the row above the message on
|
||||
|
||||
| Action | Does |
|
||||
| --- | --- |
|
||||
| Mark as unread | Puts every message in the conversation back to unread and closes it |
|
||||
| Mark as unread | Marks the newest received message unread, or the newest sent copy if none was received, and closes it |
|
||||
| Archive | Moves the conversation to Archive, so it leaves Inbox |
|
||||
| Delete | Moves the conversation to Trash, so it leaves every view except Trash |
|
||||
|
||||
|
||||
@@ -910,8 +910,8 @@ func (r *uniboxRepository) MarkSeenBulk(ctx context.Context, orgID uuid.UUID, id
|
||||
// MarkSeenByThreads is MarkSeenBulk addressed by conversation. The key is the
|
||||
// same one the list collapses on, so an id that never got a thread still
|
||||
// resolves to its own single message. Read covers the whole conversation;
|
||||
// unread, as in Gmail, only its newest received message, preferring one that
|
||||
// is not in spam or trash.
|
||||
// unread selects its newest received message, preferring one outside spam or
|
||||
// trash, or its newest sent copy if none was received. Drafts are excluded.
|
||||
func (r *uniboxRepository) MarkSeenByThreads(ctx context.Context, orgID uuid.UUID, threadIDs []string, seen bool) ([]uuid.UUID, error) {
|
||||
if len(threadIDs) == 0 {
|
||||
return nil, nil
|
||||
@@ -926,10 +926,10 @@ func (r *uniboxRepository) MarkSeenByThreads(ctx context.Context, orgID uuid.UUI
|
||||
SELECT DISTINCT ON (COALESCE(NULLIF(thread_id, ''), id::text)) id
|
||||
FROM unibox_emails
|
||||
WHERE COALESCE(NULLIF(thread_id, ''), id::text) = ANY($3)
|
||||
AND folder NOT IN ` + foldersOutbound + `
|
||||
AND folder <> 'drafts'
|
||||
AND email_id IN (SELECT id FROM email_accounts WHERE organization_id = $2)
|
||||
ORDER BY COALESCE(NULLIF(thread_id, ''), id::text),
|
||||
folder IN ('spam', 'trash'), internal_date DESC, id DESC
|
||||
folder = 'sent', folder IN ('spam', 'trash'), internal_date DESC, id DESC
|
||||
)
|
||||
RETURNING id`
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ import (
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// Mark as unread follows Gmail: the newest message received in the
|
||||
// conversation turns unread, and a sent copy never does.
|
||||
// Mark as unread prefers received mail, falling back to sent mail only when none was received.
|
||||
//
|
||||
// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \
|
||||
// go test ./internal/repository/ -run LiveUniboxMarkUnread -v
|
||||
@@ -105,7 +104,7 @@ func TestLiveUniboxMarkUnreadByThreadPrefersMailOutsideTrash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveUniboxMarkUnreadNeverReachesASentCopy(t *testing.T) {
|
||||
func TestLiveUniboxMarkUnreadSentFallbackKeepsBulkAndFolderExclusions(t *testing.T) {
|
||||
handle := liveUniboxFolderDB(t)
|
||||
f := newUniboxFolderFixture(t, handle.Pool)
|
||||
repo := NewUniboxRepository(handle)
|
||||
@@ -116,8 +115,18 @@ func TestLiveUniboxMarkUnreadNeverReachesASentCopy(t *testing.T) {
|
||||
draft := f.threadMessage(t, repo, "thread-"+uuid.NewString(), models.FolderDrafts, time.Hour)
|
||||
inbox := f.threadMessage(t, repo, "thread-"+uuid.NewString(), models.FolderInbox, time.Hour)
|
||||
|
||||
if changed, err := repo.MarkSeenByThreads(ctx, f.org, []string{sentOnly}, false); err != nil || len(changed) != 0 {
|
||||
t.Fatalf("by thread: changed = %v, err = %v; want nothing", changed, err)
|
||||
if changed, err := repo.MarkSeenByThreads(ctx, f.org, []string{sentOnly}, false); err != nil || !slices.Equal(changed, []uuid.UUID{sent}) {
|
||||
t.Fatalf("by thread: changed = %v, err = %v; want only %v", changed, err, sent)
|
||||
}
|
||||
if got := unreadIDs(t, handle.Pool, sent, draft, inbox); !slices.Equal(got, []uuid.UUID{sent}) {
|
||||
t.Fatalf("unread = %v, want only %v", got, sent)
|
||||
}
|
||||
// Reset the fallback so the bulk and folder exclusions are checked from read.
|
||||
if _, err := repo.MarkSeenByThreads(ctx, f.org, []string{sentOnly}, true); err != nil {
|
||||
t.Fatalf("MarkSeenByThreads: %v", err)
|
||||
}
|
||||
if got := unreadIDs(t, handle.Pool, sent, draft); len(got) != 0 {
|
||||
t.Fatalf("unread after reset = %v, want none", got)
|
||||
}
|
||||
changed, err := repo.MarkSeenBulk(ctx, f.org, []uuid.UUID{sent, draft, inbox}, false)
|
||||
if err != nil {
|
||||
@@ -133,3 +142,46 @@ func TestLiveUniboxMarkUnreadNeverReachesASentCopy(t *testing.T) {
|
||||
t.Fatalf("unread = %v, want no sent or draft copy", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveUniboxMarkUnreadByThreadPrefersArchiveOverNewerSent(t *testing.T) {
|
||||
handle := liveUniboxFolderDB(t)
|
||||
f := newUniboxFolderFixture(t, handle.Pool)
|
||||
repo := NewUniboxRepository(handle)
|
||||
|
||||
thread := "thread-" + uuid.NewString()
|
||||
received := f.threadMessage(t, repo, thread, models.FolderArchive, time.Hour)
|
||||
sent := f.threadMessage(t, repo, thread, models.FolderSent, time.Minute)
|
||||
|
||||
changed, err := repo.MarkSeenByThreads(context.Background(), f.org, []string{thread}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("MarkSeenByThreads: %v", err)
|
||||
}
|
||||
if !slices.Equal(changed, []uuid.UUID{received}) {
|
||||
t.Fatalf("changed = %v, want only the received copy %v", changed, received)
|
||||
}
|
||||
if got := unreadIDs(t, handle.Pool, received, sent); !slices.Equal(got, []uuid.UUID{received}) {
|
||||
t.Fatalf("unread = %v, want only %v", got, received)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveUniboxMarkUnreadByThreadTouchesNewestSentAndNeverDraft(t *testing.T) {
|
||||
handle := liveUniboxFolderDB(t)
|
||||
f := newUniboxFolderFixture(t, handle.Pool)
|
||||
repo := NewUniboxRepository(handle)
|
||||
|
||||
thread := "thread-" + uuid.NewString()
|
||||
older := f.threadMessage(t, repo, thread, models.FolderSent, 2*time.Hour)
|
||||
newest := f.threadMessage(t, repo, thread, models.FolderSent, time.Hour)
|
||||
draft := f.threadMessage(t, repo, thread, models.FolderDrafts, time.Minute)
|
||||
|
||||
changed, err := repo.MarkSeenByThreads(context.Background(), f.org, []string{thread}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("MarkSeenByThreads: %v", err)
|
||||
}
|
||||
if !slices.Equal(changed, []uuid.UUID{newest}) {
|
||||
t.Fatalf("changed = %v, want only the newest sent copy %v", changed, newest)
|
||||
}
|
||||
if got := unreadIDs(t, handle.Pool, older, newest, draft); !slices.Equal(got, []uuid.UUID{newest}) {
|
||||
t.Fatalf("unread = %v, want only %v", got, newest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,9 @@ vi.mock("@/hooks/context/socket", async (orig) => {
|
||||
const seenPatches = () =>
|
||||
calls.filter((c) => c.method === "PATCH" && c.url === "/unibox/seen");
|
||||
|
||||
const bodyGets = () =>
|
||||
calls.filter((c) => c.method === "GET" && ROWS.some((row) => c.url === `/unibox/${row.id}`));
|
||||
|
||||
const rowFor = (i: number) =>
|
||||
screen
|
||||
.getAllByText(ROWS[i].subject)
|
||||
@@ -104,6 +107,7 @@ describe("unibox mark as unread", SUITE, () => {
|
||||
const patches = seenPatches();
|
||||
expect(patches.length).toBeGreaterThan(0);
|
||||
expect(patches.every((p) => p.data?.seen === false)).toBe(true);
|
||||
expect(bodyGets()).toEqual([]);
|
||||
// By conversation only: naming every message would mark our own sent
|
||||
// copies unread too, where the server picks the newest received one.
|
||||
expect(patches[0].data).toMatchObject({ email_ids: [], thread_ids: [ROWS[0].thread_id] });
|
||||
@@ -127,5 +131,6 @@ describe("unibox mark as unread", SUITE, () => {
|
||||
const patches = seenPatches();
|
||||
expect(patches.length).toBeGreaterThan(0);
|
||||
expect(patches.every((p) => p.data?.seen === false)).toBe(true);
|
||||
expect(bodyGets()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,8 +92,14 @@ export default function useMarkSeen() {
|
||||
if (seen) queryClient.invalidateQueries({ queryKey: ["notifications", "feed"] });
|
||||
// A folder sweep touches rows we have no ids for, and unread skips
|
||||
// sent copies a row may be showing, so both re-read the lists.
|
||||
if (folder || !seen) queryClient.invalidateQueries({ queryKey: ["unibox"] });
|
||||
else {
|
||||
if (folder) queryClient.invalidateQueries({ queryKey: ["unibox"] });
|
||||
else if (!seen) {
|
||||
// A body GET marks the message read server-side.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["unibox"],
|
||||
predicate: (query) => query.queryKey[1] !== "email",
|
||||
});
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: ["unibox", "overview"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["unibox", "unseen-count"] });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user