diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 201ec0413..0a58f43da 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -209,7 +209,13 @@ A source's `column_mapping` is validated when the source is written, not on its | POST | `/unibox/agent-drafts/:id/approve` | `WRITE_UNIBOX` | | POST | `/unibox/agent-drafts/:id/discard` | `WRITE_UNIBOX` | -`PATCH /unibox/folder` re-files up to 500 messages into `inbox`, `archive` or `trash` at once. The move is Warmbly's own: the copy at the mail provider stays where it is, and the next sync will not undo it, 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/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 Warmbly's own: the copy at the mail provider stays where it is, and the next sync will not undo it, 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. + +`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. + +`POST /unibox/snooze` accepts `thread_id` for one conversation or `thread_ids` for up to 500. The single form answers with the snooze row, as before; the bulk form answers with `data`. `DELETE /unibox/snooze?thread_id=` accepts a comma-separated list. `POST /unibox/reply/draft` returns an AI-drafted reply (it never sends) grounded in the thread, the contact, and your [voice profile](/guides/unibox/#ai-reply-drafts). It charges AI credits; see [AI credits](/guides/ai-credits/). diff --git a/docs/content/docs/guides/unibox.mdx b/docs/content/docs/guides/unibox.mdx index d4aceccb5..09e6a7864 100644 --- a/docs/content/docs/guides/unibox.mdx +++ b/docs/content/docs/guides/unibox.mdx @@ -30,7 +30,9 @@ The conversation list takes the keyboard, so a triage pass never needs the mouse | `j` / `k` | Move down and up the list. From no selection, `j` takes the first conversation and `k` the last, whichever way the list is sorted. | | `g g` / `G` | Jump to the first or last conversation. | | `Enter` | Open the top conversation when nothing is selected yet. | -| `Escape` | Clear the selection. Inside the search box it gives the keyboard back to the list. | +| `x` | Tick or untick the row you are on, to build a selection. | +| `e` | Archive the selection, or the row you are on when nothing is ticked. | +| `Escape` | Clear the ticked rows, then the open conversation. Inside the search box it gives the keyboard back to the list. | | `/` | Put the caret in the list's search box. | | `c` | Label the open conversation. | @@ -55,7 +57,7 @@ Opening **Inbox** from the main navigation starts in the Inbox folder. Sent mess Each message starts in the folder the provider has it in: IMAP special-use folder attributes, Gmail labels, and Outlook well-known folders all map to the same six. Moves at the provider (junking a message, clearing it out of spam) follow on the next sync. Drafts are also reconciled: a draft the mailbox no longer holds is removed here too, so the fresh copy Gmail saves on every autosave does not leave the previous ones behind. You can also file a conversation yourself, which moves it here without moving it at the provider; see [filing a conversation](#filing-a-conversation). The active row is highlighted grey, unread counts sit on the right in blue, and hovering a folder reveals a three-dot menu with **Mark all as read**. -Spam and Trash stay out of every other view: the **All mail** view and the unread badge only count the folders you actually work. +Spam and Trash stay out of every other view, and so does Archive: filing a conversation is how you take it out of the way, so it leaves Unread, Awaiting reply, every mailbox, label and tag view, and the unread badge. **All mail** and the **Archive** folder are the two places it stays, which is where you go to find it again. The initial import that runs when a mailbox is first connected covers Inbox, Sent, Archive, and Drafts. It deliberately skips the existing contents of Spam and Trash, whose history would consume the import budget that belongs to real conversations. Both folders fill normally from the moment the mailbox is connected, so they show what arrives from then on rather than what was already there. @@ -65,9 +67,9 @@ The initial import that runs when a mailbox is first connected covers Inbox, Sen | View | Shows | | --- | --- | -| All mail | Every conversation across every mailbox (except Spam and Trash) | +| All mail | Every conversation across every mailbox, archived ones included (except Spam and Trash) | | Unread | At least one unread message | -| Awaiting reply | The other side is waiting on you | +| Awaiting reply | You wrote last and they have not answered | | Agent drafts | Conversations where the [inbox agent](/guides/inbox-agent/) has a reply waiting for review | | Snoozed | Snoozed for later | | Scheduled | Replies queued to send later | @@ -134,7 +136,7 @@ Categories label conversations. Tags label the mailboxes themselves (grouping ac ## Read state -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. +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. 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. @@ -152,6 +154,18 @@ The thread header carries three filing actions, on the row above the message on Archive and Delete both offer **Undo** on the confirmation toast. Open the Trash or Archive folder and the same header offers **Move to inbox**, so nothing filed by accident is stuck. +### From the list, without opening anything + +You do not have to open a conversation to deal with it. Hover a row (or just look at it on a phone, where they are always shown) and the right of the first line carries **Archive** and a three-dot menu with **Mark as read / unread**, **Snooze**, **Archive** or **Move to inbox**, and **Delete**. In Archive and Trash the same controls offer the way back rather than the way out. + +Archiving from a row files the whole conversation, not the message the row is showing, so it really does leave the view you filed it from. + +### Working on several at once + +Each row has a tick box in its left gutter, in place of the unread dot. Hover a row to see it, or press **Select** in the list header, which shows every box at once and adds an **All** box that takes everything currently loaded. `x` ticks the row you are on from the keyboard, and holding `Shift` while ticking takes the whole run between that row and the last one you ticked. + +With anything ticked, a bar appears along the bottom of the screen with the count and the actions that apply to all of them: **Mark read**, **Mark unread**, **Snooze**, **Archive** (or **Move to inbox**) and **Delete**. Each one is a single request, so filing a screenful is as quick as filing one. **Clear** drops the selection, and so does `Escape`; changing view drops it too, since the rows it applied to are no longer the rows on screen. + Unlike read state, Archive and Delete are Warmbly's own filing. The message keeps its place in Gmail, Outlook, or whatever mail client the mailbox belongs to, and deleting a conversation here never deletes mail there. The next sync will not undo your filing either: Warmbly records where the provider has each message separately from where you filed it, and follows the provider only when the provider itself moves the message. So junking a message in Gmail still reaches Warmbly, and an ordinary sync pass does not. diff --git a/docs/public/openapi.json b/docs/public/openapi.json index f5642c228..2a6920bee 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -8140,7 +8140,7 @@ "get": { "operationId": "unibox_list", "summary": "List incoming mail", - "description": "Org-wide inbox list, collapsed to one row per thread (newest message), with filtering and cursor pagination. Excludes snoozed threads unless `snoozed=true`, and the `spam` and `trash` folders unless `folder` selects one of them.", + "description": "Org-wide inbox list, collapsed to one row per thread (newest message), with filtering and cursor pagination. Excludes snoozed threads unless `snoozed=true`, and the `spam`, `trash` and `archive` folders unless `folder` selects one of them or `include_archived=true` is passed.", "tags": [ "unibox" ], @@ -8192,7 +8192,7 @@ "name": "folder", "in": "query", "required": false, - "description": "Canonical folder scope. Omit for every folder except `spam` and `trash`.", + "description": "Canonical folder scope. Omit for every working folder: `spam`, `trash` and `archive` stay out.", "schema": { "type": "string", "enum": [ @@ -8205,6 +8205,15 @@ ] } }, + { + "name": "include_archived", + "in": "query", + "required": false, + "description": "`true` puts archived conversations back into the result, which is what the All mail view does. Ignored when `folder` is set.", + "schema": { + "type": "boolean" + } + }, { "name": "unseen", "in": "query", @@ -8218,7 +8227,7 @@ "name": "awaiting_reply", "in": "query", "required": false, - "description": "`true` returns only threads whose latest message was sent by you.", + "description": "`true` returns only threads whose latest message was sent by one of your mailboxes.", "schema": { "type": "boolean" } @@ -8751,7 +8760,7 @@ "patch": { "operationId": "unibox_mark_seen", "summary": "Mark messages seen", - "description": "Marks a batch of messages as read or unread, org-wide. Up to 500 ids per call.", + "description": "Marks messages as read or unread, org-wide. Name them with `email_ids`, whole conversations with `thread_ids`, or sweep a folder with `folder`. Up to 500 ids of each per call.", "tags": [ "unibox" ], @@ -8832,8 +8841,8 @@ "/unibox/folder": { "patch": { "operationId": "unibox_move_folder", - "summary": "Move messages between folders", - "description": "Re-files a batch of messages into Inbox, Archive or Trash, org-wide. Up to 500 ids per call.\n\nThis is a move in Warmbly only. The copy at the mail provider stays where it is, and a later sync will not undo the move: Warmbly tracks the provider's own placement separately and follows it only when the provider itself moves the message. `sent`, `drafts` and `spam` are placements the provider reaches, so they are rejected here with a `400`.", + "summary": "Move conversations between folders", + "description": "Re-files messages into Inbox, Archive or Trash, org-wide. Name them with `email_ids`, whole conversations with `thread_ids`, or both; up to 500 of each. Prefer `thread_ids`: filing part of a conversation leaves it listed.\n\nThis is a move in Warmbly only. The copy at the mail provider stays where it is, and a later sync will not undo the move: Warmbly tracks the provider's own placement separately and follows it only when the provider itself moves the message. `sent`, `drafts` and `spam` are placements the provider reaches, so they are rejected here with a `400`.", "tags": [ "unibox" ], @@ -9063,8 +9072,8 @@ "/unibox/snooze": { "post": { "operationId": "unibox_snooze", - "summary": "Snooze a thread", - "description": "Hides a thread from your inbox until `snoozed_until` passes. Upsert semantics: a second call on the same thread updates the time in place.", + "summary": "Snooze one or more threads", + "description": "Hides threads from your inbox until `snoozed_until` passes. Upsert semantics: a second call on the same thread updates the time in place. A request naming one thread answers with that row; one naming several answers with `data`.", "tags": [ "unibox" ], @@ -9143,8 +9152,8 @@ }, "delete": { "operationId": "unibox_unsnooze", - "summary": "Unsnooze a thread", - "description": "Un-snoozes a thread immediately. Idempotent: deleting a snooze that does not exist still succeeds with 204.", + "summary": "Unsnooze one or more threads", + "description": "Un-snoozes threads immediately. Idempotent: deleting a snooze that does not exist still succeeds with 204.", "tags": [ "unibox" ], @@ -9158,7 +9167,7 @@ "name": "thread_id", "in": "query", "required": true, - "description": "The thread to un-snooze.", + "description": "The thread to un-snooze, or a comma-separated list of them.", "schema": { "type": "string" } @@ -26192,7 +26201,7 @@ }, "UniboxMarkSeenRequest": { "type": "object", - "description": "Also the echoed response body. Send one of `email_ids` or `folder`, not both.", + "description": "Also the echoed response body. Name the messages with `email_ids`, whole conversations with `thread_ids`, or both. `folder` sweeps one folder instead and cannot be combined with either.", "properties": { "email_ids": { "type": "array", @@ -26203,6 +26212,14 @@ "maxItems": 500, "description": "Message UUIDs to update (max 500)." }, + "thread_ids": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 500, + "description": "Thread ids whose every message to update (max 500). Use the `thread_id` of a list row, or its `id` for mail that has none." + }, "folder": { "type": "string", "enum": [ @@ -26223,9 +26240,8 @@ }, "UniboxMoveFolderRequest": { "type": "object", - "description": "Also the echoed response body.", + "description": "Also the echoed response body. Name the messages with `email_ids`, whole conversations with `thread_ids`, or both. Prefer `thread_ids`: filing part of a conversation leaves it listed, because a thread shows wherever any message of it still sits.", "required": [ - "email_ids", "folder" ], "properties": { @@ -26238,6 +26254,14 @@ "maxItems": 500, "description": "Message UUIDs to move (max 500)." }, + "thread_ids": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 500, + "description": "Thread ids whose every message to re-file (max 500). Use the `thread_id` of a list row, or its `id` for mail that has none." + }, "folder": { "type": "string", "enum": [ @@ -26348,7 +26372,6 @@ "UniboxSnoozeRequest": { "type": "object", "required": [ - "thread_id", "snoozed_until" ], "properties": { @@ -26356,12 +26379,21 @@ "type": "string", "description": "The thread to snooze." }, + "thread_ids": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 500, + "description": "Threads to snooze (max 500), for filing a whole selection in one call." + }, "snoozed_until": { "type": "string", "format": "date-time", "description": "RFC 3339 timestamp to un-hide the thread." } - } + }, + "description": "Name one conversation with `thread_id` or up to 500 with `thread_ids`. The single form answers with the snooze row; the bulk form answers with `data`." }, "UniboxSnooze": { "type": "object", diff --git a/internal/api/handler/unibox.go b/internal/api/handler/unibox.go index 679e8bf4e..1e292cd85 100644 --- a/internal/api/handler/unibox.go +++ b/internal/api/handler/unibox.go @@ -80,8 +80,9 @@ func (h *Handler) GetUniboxIncoming(c *gin.Context) { params.Direction = &direction } - // Folder scope. Absent = every folder except spam and trash; an - // unknown value is a 400 rather than silently widening the result. + // Folder scope. Absent = every working folder (spam, trash and archive + // stay out); an unknown value is a 400 rather than silently widening the + // result. if folder := c.Query("folder"); folder != "" { if !models.ValidFolder(folder) { errx.Handle(c, errx.ErrUniboxFolder) @@ -90,6 +91,12 @@ func (h *Handler) GetUniboxIncoming(c *gin.Context) { params.Folder = &folder } + // All mail and reference reads ask for filed conversations back. + if c.Query("include_archived") == "true" { + v := true + params.IncludeArchived = &v + } + // Parse subject filter if subject := c.Query("subject"); subject != "" { params.Subject = &subject @@ -413,6 +420,7 @@ func (h *Handler) UniboxMoveFolder(c *gin.Context) { "action": "move_folder", "folder": data.Folder, "messages": strconv.Itoa(len(data.EmailIDs)), + "threads": strconv.Itoa(len(data.ThreadIDs)), }) c.JSON(http.StatusOK, resp) @@ -559,12 +567,26 @@ func (h *Handler) GetUniboxOverview(c *gin.Context) { } type UniboxSnoozeRequest struct { - ThreadID string `json:"thread_id" binding:"required"` + ThreadID string `json:"thread_id"` + // ThreadIDs snoozes a whole selection in one call. Either field will do; + // naming both snoozes the union. + ThreadIDs []string `json:"thread_ids"` SnoozedUntil time.Time `json:"snoozed_until" binding:"required"` } -// CreateUniboxSnooze hides a thread until snoozed_until passes. +// threads is every conversation the request names, in either field. +func (r UniboxSnoozeRequest) threads() []string { + out := r.ThreadIDs + if r.ThreadID != "" { + out = append([]string{r.ThreadID}, out...) + } + return out +} + +// CreateUniboxSnooze hides conversations until snoozed_until passes. // Upsert semantics: a second call on the same thread updates the time. +// A request naming one thread answers with that row, as it always has; one +// naming several answers with `data`. // POST /unibox/snooze func (h *Handler) CreateUniboxSnooze(c *gin.Context) { if !h.gateUnibox(c) { @@ -583,18 +605,23 @@ func (h *Handler) CreateUniboxSnooze(c *gin.Context) { return } - resp, xerr := h.UniboxService.Snooze(c.Request.Context(), uid, req.ThreadID, req.SnoozedUntil) + threads := req.threads() + rows, xerr := h.UniboxService.Snooze(c.Request.Context(), uid, threads, req.SnoozedUntil) if xerr != nil { errx.Handle(c, xerr) return } h.auditOrg(c, models.AuditActionCreate, models.AuditEntityUnibox, nil, nil, map[string]string{ - "action": "snooze", - "thread_id": req.ThreadID, + "action": "snooze", + "threads": strconv.Itoa(len(threads)), }) - c.JSON(http.StatusOK, resp) + if len(threads) == 1 && len(rows) == 1 { + c.JSON(http.StatusOK, rows[0]) + return + } + c.JSON(http.StatusOK, gin.H{"data": rows}) } // DeleteUniboxSnooze un-snoozes a thread immediately. Idempotent — @@ -611,20 +638,27 @@ func (h *Handler) DeleteUniboxSnooze(c *gin.Context) { return } - threadID := c.Query("thread_id") - if threadID == "" { + // One id, or a comma-separated selection. Same shape as email_ids on the + // other list endpoints. + var threads []string + for _, raw := range strings.Split(c.Query("thread_id"), ",") { + if id := strings.TrimSpace(raw); id != "" { + threads = append(threads, id) + } + } + if len(threads) == 0 { errx.Handle(c, errx.New(errx.BadRequest, "thread_id is required")) return } - if xerr := h.UniboxService.Unsnooze(c.Request.Context(), uid, threadID); xerr != nil { + if xerr := h.UniboxService.Unsnooze(c.Request.Context(), uid, threads); xerr != nil { errx.Handle(c, xerr) return } h.auditOrg(c, models.AuditActionDelete, models.AuditEntityUnibox, nil, nil, map[string]string{ - "action": "snooze", - "thread_id": threadID, + "action": "snooze", + "threads": strconv.Itoa(len(threads)), }) c.Status(http.StatusNoContent) diff --git a/internal/app/aitools/tools_inbox.go b/internal/app/aitools/tools_inbox.go index c7a70c231..7f4c4ebb5 100644 --- a/internal/app/aitools/tools_inbox.go +++ b/internal/app/aitools/tools_inbox.go @@ -174,7 +174,7 @@ func (d Deps) snoozeThread(ctx context.Context, inv Invocation, args json.RawMes if perr != nil { return "", ErrInvalidArgs } - snooze, xerr := d.Unibox.Snooze(ctx, inv.UserID, in.ThreadID, until) + snooze, xerr := d.Unibox.Snooze(ctx, inv.UserID, []string{in.ThreadID}, until) if xerr != nil { return "", fromErrx(xerr) } @@ -195,7 +195,7 @@ func (d Deps) unsnoozeThread(ctx context.Context, inv Invocation, args json.RawM if in.ThreadID == "" { return "", ErrInvalidArgs } - if xerr := d.Unibox.Unsnooze(ctx, inv.UserID, in.ThreadID); xerr != nil { + if xerr := d.Unibox.Unsnooze(ctx, inv.UserID, []string{in.ThreadID}); xerr != nil { return "", fromErrx(xerr) } d.logAudit(ctx, inv, models.AuditActionUpdate, models.AuditEntityUnibox, nil, nil) diff --git a/internal/app/aitools/tools_unibox.go b/internal/app/aitools/tools_unibox.go index aa061f999..bea44fdcc 100644 --- a/internal/app/aitools/tools_unibox.go +++ b/internal/app/aitools/tools_unibox.go @@ -16,12 +16,13 @@ func (d Deps) registerUniboxTools(r *Registry) { Name: "list_threads", Description: "List unified-inbox conversation threads (received/synced mail), newest first, with optional filters. The inbox only contains synced mailbox mail; to find leads that never replied or went cold, use list_campaign_leads instead. If a filtered call returns 0, retry without filters before concluding the inbox is empty.", InputSchema: objectSchema(map[string]any{ - "subject": strProp("Optional subject contains filter."), - "sender": strProp("Optional sender email filter."), - "folder": strProp("Optional folder: inbox, sent, drafts, archive, spam or trash. Omit for every folder except spam and trash."), - "unseen_only": boolProp("Only threads with unread messages."), - "awaiting_reply": boolProp("Only threads whose LATEST message was sent by one of our mailboxes (we spoke last, still waiting on them)."), - "limit": intProp("Max threads (1-50, default 20)."), + "subject": strProp("Optional subject contains filter."), + "sender": strProp("Optional sender email filter."), + "folder": strProp("Optional folder: inbox, sent, drafts, archive, spam or trash. Omit for every working folder (spam, trash and archive stay out)."), + "include_archived": boolProp("Put archived conversations back into an unscoped list, the way the All mail view does. Ignored when folder is set."), + "unseen_only": boolProp("Only threads with unread messages."), + "awaiting_reply": boolProp("Only threads whose LATEST message was sent by one of our mailboxes (we spoke last, still waiting on them)."), + "limit": intProp("Max threads (1-50, default 20)."), }), Risk: generation.RiskRead, RequiredOrgPerm: models.PermAccessUnibox, @@ -78,12 +79,13 @@ func (d Deps) listThreads(ctx context.Context, inv Invocation, args json.RawMess return "", err } in, err := decodeArgs[struct { - Subject string `json:"subject"` - Sender string `json:"sender"` - Folder string `json:"folder"` - UnseenOnly bool `json:"unseen_only"` - AwaitingReply bool `json:"awaiting_reply"` - Limit int `json:"limit"` + Subject string `json:"subject"` + Sender string `json:"sender"` + Folder string `json:"folder"` + IncludeArchived bool `json:"include_archived"` + UnseenOnly bool `json:"unseen_only"` + AwaitingReply bool `json:"awaiting_reply"` + Limit int `json:"limit"` }](args) if err != nil { return "", err @@ -105,6 +107,10 @@ func (d Deps) listThreads(ctx context.Context, inv Invocation, args json.RawMess if in.Folder != "" { params.Folder = &in.Folder } + if in.IncludeArchived { + t := true + params.IncludeArchived = &t + } if in.UnseenOnly { t := true params.Unseen = &t diff --git a/internal/app/unibox/config.go b/internal/app/unibox/config.go index 8381ea831..26474fbcf 100644 --- a/internal/app/unibox/config.go +++ b/internal/app/unibox/config.go @@ -23,4 +23,8 @@ const ( // Anything longer is functionally "mute" — we'd rather expose a // dedicated mute action than let snooze drift into archive. SnoozeMaxHorizon = 90 * 24 * time.Hour + + // SnoozeMaxThreads bounds one selection-bar press, matching the id cap + // the read-state and filing endpoints apply. + SnoozeMaxThreads = 500 ) diff --git a/internal/app/unibox/seen.go b/internal/app/unibox/seen.go index 4f08a650b..731d7d1d8 100644 --- a/internal/app/unibox/seen.go +++ b/internal/app/unibox/seen.go @@ -21,14 +21,14 @@ func (s *uniboxService) MarkSeen(ctx context.Context, userID, emailID uuid.UUID, } func (s *uniboxService) MarkSeenBulk(ctx context.Context, orgID uuid.UUID, data *models.MarkSeen) (*models.MarkSeen, *errx.Error) { - if len(data.EmailIDs) > 500 { + if len(data.EmailIDs) > 500 || len(data.ThreadIDs) > 500 { return nil, errx.ErrSeenMax } // A folder sweep and an id list are different requests; refuse the // ambiguous combination instead of guessing which one was meant. if data.Folder != "" { - if len(data.EmailIDs) > 0 { + if len(data.EmailIDs) > 0 || len(data.ThreadIDs) > 0 { return nil, errx.ErrSeenFolderAndIDs } if !models.ValidFolder(data.Folder) { @@ -43,10 +43,24 @@ func (s *uniboxService) MarkSeenBulk(ctx context.Context, orgID uuid.UUID, data return data, nil } - changed, err := s.uniboxRepository.MarkSeenBulk(ctx, orgID, data.EmailIDs, data.Seen) - if err != nil { - errs.CaptureException(err) - return nil, errx.InternalError() + // Conversations and ids can arrive together: the list marks a row read by + // thread, the reader marks the messages it has open by id. + var changed []uuid.UUID + if len(data.ThreadIDs) > 0 { + byThread, err := s.uniboxRepository.MarkSeenByThreads(ctx, orgID, data.ThreadIDs, data.Seen) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + changed = append(changed, byThread...) + } + if len(data.EmailIDs) > 0 { + byID, err := s.uniboxRepository.MarkSeenBulk(ctx, orgID, data.EmailIDs, data.Seen) + if err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + changed = append(changed, byID...) } s.relaySeen(ctx, orgID, changed) @@ -139,7 +153,7 @@ func (s *uniboxService) publishSeenRelay(ctx context.Context, orgID uuid.UUID, c // Store-side only: the provider copy stays where it is, and provider_folder is // left alone so the sync can still tell a real provider move from a flag scan. func (s *uniboxService) MoveFolderBulk(ctx context.Context, orgID uuid.UUID, data *models.MoveFolder) (*models.MoveFolder, *errx.Error) { - if len(data.EmailIDs) > 500 { + if len(data.EmailIDs) > 500 || len(data.ThreadIDs) > 500 { return nil, errx.ErrSeenMax } // Only the three a user can file into. sent/drafts/spam are verdicts the @@ -147,6 +161,12 @@ func (s *uniboxService) MoveFolderBulk(ctx context.Context, orgID uuid.UUID, dat if !models.FilableFolder(data.Folder) { return nil, errx.ErrUniboxFilableFolder } + // Filing by conversation is what the list rows use; the reader still names + // the messages it has loaded. + if err := s.uniboxRepository.MoveThreadsToFolder(ctx, orgID, data.ThreadIDs, data.Folder); err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } if err := s.uniboxRepository.MoveToFolderBulk(ctx, orgID, data.EmailIDs, data.Folder); err != nil { errs.CaptureException(err) return nil, errx.InternalError() diff --git a/internal/app/unibox/service.go b/internal/app/unibox/service.go index 5d9a0bef0..7f6f7ba39 100644 --- a/internal/app/unibox/service.go +++ b/internal/app/unibox/service.go @@ -47,9 +47,10 @@ type UniboxService interface { MarkSeenBulk(ctx context.Context, orgID uuid.UUID, data *models.MarkSeen) (*models.MarkSeen, *errx.Error) MoveFolderBulk(ctx context.Context, orgID uuid.UUID, data *models.MoveFolder) (*models.MoveFolder, *errx.Error) - // Snooze hides a thread until `until`. Unsnooze drops the row. - Snooze(ctx context.Context, userID uuid.UUID, threadID string, until time.Time) (*models.UniboxSnooze, *errx.Error) - Unsnooze(ctx context.Context, userID uuid.UUID, threadID string) *errx.Error + // Snooze hides conversations until `until`. Unsnooze drops the rows. Both + // take a set so the list's selection bar is one call, not one per row. + Snooze(ctx context.Context, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, *errx.Error) + Unsnooze(ctx context.Context, userID uuid.UUID, threadIDs []string) *errx.Error ListSnoozes(ctx context.Context, userID uuid.UUID) ([]models.UniboxSnooze, *errx.Error) // Overview powers the scope rail + top metric strip in one call. diff --git a/internal/app/unibox/snooze.go b/internal/app/unibox/snooze.go index cc7724584..1740190fc 100644 --- a/internal/app/unibox/snooze.go +++ b/internal/app/unibox/snooze.go @@ -2,6 +2,7 @@ package unibox import ( "context" + "strings" "time" "github.com/google/uuid" @@ -10,10 +11,16 @@ import ( "github.com/warmbly/warmbly/internal/observability/errs" ) -func (s *uniboxService) Snooze(ctx context.Context, userID uuid.UUID, threadID string, until time.Time) (*models.UniboxSnooze, *errx.Error) { - if threadID == "" { +// Snooze takes a set of conversations so the list's selection bar is one call. +// A single thread is the one-element case. +func (s *uniboxService) Snooze(ctx context.Context, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, *errx.Error) { + threadIDs = nonEmpty(threadIDs) + if len(threadIDs) == 0 { return nil, errx.New(errx.BadRequest, "thread_id is required") } + if len(threadIDs) > SnoozeMaxThreads { + return nil, errx.ErrSeenMax + } now := time.Now() // Tiny lead-time grace so a click that takes a few hundred ms // over the wire doesn't blow up validation when the user picked @@ -25,25 +32,41 @@ func (s *uniboxService) Snooze(ctx context.Context, userID uuid.UUID, threadID s return nil, errx.New(errx.BadRequest, "snoozed_until is too far in the future (max 90 days)") } - row, err := s.uniboxRepository.UpsertSnooze(ctx, userID, threadID, until.UTC()) + rows, err := s.uniboxRepository.UpsertSnoozes(ctx, userID, threadIDs, until.UTC()) if err != nil { errs.CaptureException(err) return nil, errx.InternalError() } - return row, nil + return rows, nil } -func (s *uniboxService) Unsnooze(ctx context.Context, userID uuid.UUID, threadID string) *errx.Error { - if threadID == "" { +func (s *uniboxService) Unsnooze(ctx context.Context, userID uuid.UUID, threadIDs []string) *errx.Error { + threadIDs = nonEmpty(threadIDs) + if len(threadIDs) == 0 { return errx.New(errx.BadRequest, "thread_id is required") } - if err := s.uniboxRepository.DeleteSnooze(ctx, userID, threadID); err != nil { + if len(threadIDs) > SnoozeMaxThreads { + return errx.ErrSeenMax + } + if err := s.uniboxRepository.DeleteSnoozes(ctx, userID, threadIDs); err != nil { errs.CaptureException(err) return errx.InternalError() } return nil } +// nonEmpty drops blank ids, so one empty string in a list is not a request to +// snooze a conversation that does not exist. +func nonEmpty(in []string) []string { + out := make([]string, 0, len(in)) + for _, s := range in { + if strings.TrimSpace(s) != "" { + out = append(out, s) + } + } + return out +} + func (s *uniboxService) ListSnoozes(ctx context.Context, userID uuid.UUID) ([]models.UniboxSnooze, *errx.Error) { rows, err := s.uniboxRepository.ListSnoozes(ctx, userID) if err != nil { diff --git a/internal/models/unibox.go b/internal/models/unibox.go index 2541961f9..3b45461f3 100644 --- a/internal/models/unibox.go +++ b/internal/models/unibox.go @@ -332,15 +332,24 @@ type MailSearchParams struct { // conversation labels at all. nil = no filter. Uncategorized *bool // Folder narrows to one canonical folder (inbox/sent/drafts/archive/ - // spam/trash). nil = every folder except spam and trash, so junk never - // bleeds into the combined view. - Folder *string - PageSize int - Cursor string + // spam/trash). nil = every working folder, so junk and filed mail never + // bleed into the combined view. + Folder *string + // IncludeArchived puts archived conversations back into an unscoped + // result. Filing is how a conversation leaves the working views, so it + // has to leave all of them; only "All mail" and reference reads (compose + // history) ask for it. Ignored when Folder names one. + IncludeArchived *bool + PageSize int + Cursor string } type MarkSeen struct { EmailIDs []uuid.UUID `json:"email_ids"` + // ThreadIDs marks whole conversations, so a caller holding a list row + // does not have to fetch the thread to learn its message ids. Each entry + // is a thread id, or a message id for mail that never got one. + ThreadIDs []string `json:"thread_ids,omitempty"` // Folder, when set, marks every unread message in that folder for the // whole workspace instead of the explicit id list. Folder string `json:"folder,omitempty"` @@ -352,7 +361,12 @@ type MarkSeen struct { // is not moved, so the message stays where it is in the user's mail client. type MoveFolder struct { EmailIDs []uuid.UUID `json:"email_ids"` - Folder string `json:"folder"` + // ThreadIDs files whole conversations. A row in the list knows its thread + // but not the ids inside it, and filing half a conversation leaves it in + // the view it was filed out of. Each entry is a thread id, or a message id + // for mail that never got one. + ThreadIDs []string `json:"thread_ids,omitempty"` + Folder string `json:"folder"` } // UniboxSnooze hides a thread from the user's inbox until SnoozedUntil diff --git a/internal/repository/pg_unibox.go b/internal/repository/pg_unibox.go index 3fcce5377..b5ad0d7f9 100644 --- a/internal/repository/pg_unibox.go +++ b/internal/repository/pg_unibox.go @@ -51,12 +51,19 @@ type UniboxRepository interface { // MarkSeenBulk flips the read state of the given messages and returns the // ids that actually changed, which is what gets relayed to the provider. MarkSeenBulk(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID, seen bool) ([]uuid.UUID, error) + // MarkSeenByThreads is MarkSeenBulk addressed by conversation, for callers + // that hold a list row rather than the ids inside it. + MarkSeenByThreads(ctx context.Context, orgID uuid.UUID, threadIDs []string, seen bool) ([]uuid.UUID, error) // MarkSeenByFolder flips the read state of every message in one canonical // folder for the whole workspace (the sidebar's "mark all as read"). MarkSeenByFolder(ctx context.Context, orgID uuid.UUID, folder string, seen bool) ([]uuid.UUID, error) // MoveToFolderBulk re-files the given messages into one canonical folder, // org-scoped like MarkSeenBulk. MoveToFolderBulk(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID, folder string) error + // MoveThreadsToFolder files whole conversations. Filing part of one leaves + // it in the view it was filed out of, which reads as the action having + // done nothing. + MoveThreadsToFolder(ctx context.Context, orgID uuid.UUID, threadIDs []string, folder string) error // SeenRelayTargets names the given messages the way their provider does, // with the worker holding each mailbox. Rows whose mailbox has no worker // are left out: there is nothing to relay through. @@ -76,8 +83,10 @@ type UniboxRepository interface { // snoozed_until even if one already exists; DeleteSnooze removes // the row outright (instant un-snooze). ListSnoozes returns the // active set for the user. - UpsertSnooze(ctx context.Context, userID uuid.UUID, threadID string, until time.Time) (*models.UniboxSnooze, error) - DeleteSnooze(ctx context.Context, userID uuid.UUID, threadID string) error + // The bulk forms back the conversation list's selection bar, so filing a + // screenful of mail is one round trip rather than one per row. + UpsertSnoozes(ctx context.Context, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, error) + DeleteSnoozes(ctx context.Context, userID uuid.UUID, threadIDs []string) error ListSnoozes(ctx context.Context, userID uuid.UUID) ([]models.UniboxSnooze, error) // Overview powers the scope rail + top metric strip. Single call @@ -145,6 +154,37 @@ var mailFieldsPreview = []string{ "subject", "snippet", "internal_date", "seen", } +// Folders a view never shows unless it names one. Junk is junk; archive is +// where a conversation goes to leave the views the workspace works out of, so +// filing one has to take it out of all of them and not only the Inbox folder. +// "All mail" and reference reads opt archive back in. +const ( + foldersOutsideWorkingViews = `('spam', 'trash', 'archive')` + foldersOutsideAllMail = `('spam', 'trash')` +) + +// bareAddrSQL extracts the exact address out of a raw header entry: the +// bracketed form every provider writes ("Name "), the parenthesised +// form older IMAP rows carry, else the trimmed value. Lowercased, and never a +// substring contains, so a@b.com matches neither xa@b.com nor a@b.com.evil. +func bareAddrSQL(expr string) string { + return fmt.Sprintf( + `lower(coalesce(substring(%[1]s from '<([^>]*)>'), substring(%[1]s from '\(([^()]*)\)\s*$'), btrim(%[1]s)))`, + expr, + ) +} + +// ownAddressSQL is the predicate "this header entry names a mailbox of the +// organization in $1". The addresses are compared exactly; matching them by +// containment is what made Awaiting reply nearly empty, since a mailbox sends +// as "Name " and almost never as a bare address. +func ownAddressSQL(arrayExpr, orgArg string) string { + return fmt.Sprintf(`EXISTS ( + SELECT 1 FROM unnest(%s) AS own(addr) + WHERE %s IN (SELECT lower(ea.email) FROM email_accounts ea WHERE ea.organization_id = %s) + )`, arrayExpr, bareAddrSQL("own.addr"), orgArg) +} + func (r *uniboxRepository) CreateEntry(ctx context.Context, userID uuid.UUID, e *models.EmailMessageStoreData) error { query := ` INSERT INTO unibox_emails ( @@ -460,14 +500,17 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID uuid.UUID, params * FROM unibox_emails ue WHERE ue.email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1)`, strings.Join(previewCols, ", ")) - // Folder scoping. nil = every folder except spam and trash, so junk - // never bleeds into the combined view. - if params.Folder != nil { + // Folder scoping. nil = every working folder; IncludeArchived widens that + // to everything except junk, which is what "All mail" means. + switch { + case params.Folder != nil: inner += fmt.Sprintf(` AND ue.folder = $%d`, argPos) args = append(args, *params.Folder) argPos++ - } else { - inner += ` AND ue.folder NOT IN ('spam', 'trash')` + case params.IncludeArchived != nil && *params.IncludeArchived: + inner += ` AND ue.folder NOT IN ` + foldersOutsideAllMail + default: + inner += ` AND ue.folder NOT IN ` + foldersOutsideWorkingViews } // Snooze handling. nil = exclude snoozed (the inbox default), so @@ -579,22 +622,13 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID uuid.UUID, params * argPos++ } - // Direction resolves against the org's own mailbox addresses. from_addr - // entries can be raw headers ("Name "), so match by containment - // rather than exact ANY(). + // Direction resolves against the org's own mailbox addresses, read out of + // the raw header entry rather than compared to it whole. switch { case params.Direction != nil && *params.Direction == "sent": - inner += ` AND EXISTS ( - SELECT 1 FROM unnest(ue.from_addr) AS f(addr) - JOIN email_accounts ea2 ON ea2.organization_id = $1 - WHERE f.addr ILIKE '%' || ea2.email || '%' - )` + inner += ` AND ` + ownAddressSQL("ue.from_addr", "$1") case params.Direction != nil && *params.Direction == "received": - inner += ` AND NOT EXISTS ( - SELECT 1 FROM unnest(ue.from_addr) AS f(addr) - JOIN email_accounts ea2 ON ea2.organization_id = $1 - WHERE f.addr ILIKE '%' || ea2.email || '%' - )` + inner += ` AND NOT ` + ownAddressSQL("ue.from_addr", "$1") } if len(params.EmailAccountIDs) > 0 { @@ -627,11 +661,7 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID uuid.UUID, params * // the user's own mailboxes — i.e. they're waiting on the recipient. if params.AwaitingReply != nil && *params.AwaitingReply { query += ` - AND EXISTS ( - SELECT 1 FROM email_accounts ea - WHERE ea.organization_id = $1 - AND ea.email = ANY(b.from_addr) - )` + AND ` + ownAddressSQL("b.from_addr", "$1") } // Agent drafts: threads with a pending inbox-agent draft awaiting review. @@ -695,7 +725,7 @@ func (r *uniboxRepository) GetUnseenCount(ctx context.Context, orgID uuid.UUID, FROM unibox_emails WHERE email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1) AND email_id = $2 AND seen = FALSE - AND folder NOT IN ('spam', 'trash')`, + AND folder NOT IN `+foldersOutsideWorkingViews, orgID, *emailAccountID, ).Scan(&count) return count, err @@ -705,7 +735,7 @@ func (r *uniboxRepository) GetUnseenCount(ctx context.Context, orgID uuid.UUID, `SELECT COUNT(DISTINCT COALESCE(NULLIF(thread_id, ''), id::text)) FROM unibox_emails WHERE email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1) AND seen = FALSE - AND folder NOT IN ('spam', 'trash')`, + AND folder NOT IN `+foldersOutsideWorkingViews, orgID, ).Scan(&count) return count, err @@ -753,6 +783,36 @@ func (r *uniboxRepository) MarkSeenBulk(ctx context.Context, orgID uuid.UUID, id return changed, rows.Err() } +// 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. +func (r *uniboxRepository) MarkSeenByThreads(ctx context.Context, orgID uuid.UUID, threadIDs []string, seen bool) ([]uuid.UUID, error) { + if len(threadIDs) == 0 { + return nil, nil + } + rows, err := r.db.Query(ctx, + `UPDATE unibox_emails SET seen = $1, updated_at = NOW() + WHERE COALESCE(NULLIF(thread_id, ''), id::text) = ANY($3) AND seen <> $1 + AND email_id IN (SELECT id FROM email_accounts WHERE organization_id = $2) + RETURNING id`, + seen, orgID, threadIDs, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + changed := make([]uuid.UUID, 0, len(threadIDs)) + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + changed = append(changed, id) + } + return changed, rows.Err() +} + // MarkSeenByFolder flips the read state of every message in one folder, // org-scoped like MarkSeenBulk (the sidebar's "mark all as read"). func (r *uniboxRepository) MarkSeenByFolder(ctx context.Context, orgID uuid.UUID, folder string, seen bool) ([]uuid.UUID, error) { @@ -826,6 +886,22 @@ func (r *uniboxRepository) MoveToFolderBulk(ctx context.Context, orgID uuid.UUID return err } +// MoveThreadsToFolder files every message in the named conversations. Filing +// by thread rather than by id is what makes a list row able to archive what it +// shows: the row knows the conversation, not the messages inside it. +func (r *uniboxRepository) MoveThreadsToFolder(ctx context.Context, orgID uuid.UUID, threadIDs []string, folder string) error { + if len(threadIDs) == 0 { + return nil + } + _, err := r.db.Exec(ctx, + `UPDATE unibox_emails SET folder = $1, updated_at = NOW() + WHERE COALESCE(NULLIF(thread_id, ''), id::text) = ANY($3) + AND email_id IN (SELECT id FROM email_accounts WHERE organization_id = $2)`, + folder, orgID, threadIDs, + ) + return err +} + func (r *uniboxRepository) Delete(ctx context.Context, userID, id uuid.UUID) error { tx, err := r.db.Begin(ctx) if err != nil { @@ -1108,30 +1184,41 @@ func (r *uniboxRepository) LatestMessageIDInThread(ctx context.Context, orgID uu // ── Snoozes ──────────────────────────────────────────────────────────── -func (r *uniboxRepository) UpsertSnooze(ctx context.Context, userID uuid.UUID, threadID string, until time.Time) (*models.UniboxSnooze, error) { - if threadID == "" { - return nil, errors.New("threadID required") +func (r *uniboxRepository) UpsertSnoozes(ctx context.Context, userID uuid.UUID, threadIDs []string, until time.Time) ([]models.UniboxSnooze, error) { + if len(threadIDs) == 0 { + return nil, errors.New("threadIDs required") } - row := r.db.QueryRow(ctx, ` + rows, err := r.db.Query(ctx, ` INSERT INTO unibox_snoozes (user_id, thread_id, snoozed_until, created_at, updated_at) - VALUES ($1, $2, $3, NOW(), NOW()) + SELECT $1, t, $3, NOW(), NOW() FROM unnest($2::text[]) AS t ON CONFLICT (user_id, thread_id) DO UPDATE SET snoozed_until = EXCLUDED.snoozed_until, updated_at = NOW() RETURNING id, user_id, thread_id, snoozed_until, created_at, updated_at - `, userID, threadID, until) - - var s models.UniboxSnooze - if err := row.Scan(&s.ID, &s.UserID, &s.ThreadID, &s.SnoozedUntil, &s.CreatedAt, &s.UpdatedAt); err != nil { + `, userID, threadIDs, until) + if err != nil { return nil, err } - return &s, nil + defer rows.Close() + + out := make([]models.UniboxSnooze, 0, len(threadIDs)) + for rows.Next() { + var s models.UniboxSnooze + if err := rows.Scan(&s.ID, &s.UserID, &s.ThreadID, &s.SnoozedUntil, &s.CreatedAt, &s.UpdatedAt); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() } -func (r *uniboxRepository) DeleteSnooze(ctx context.Context, userID uuid.UUID, threadID string) error { +func (r *uniboxRepository) DeleteSnoozes(ctx context.Context, userID uuid.UUID, threadIDs []string) error { + if len(threadIDs) == 0 { + return nil + } _, err := r.db.Exec(ctx, - `DELETE FROM unibox_snoozes WHERE user_id = $1 AND thread_id = $2`, - userID, threadID, + `DELETE FROM unibox_snoozes WHERE user_id = $1 AND thread_id = ANY($2)`, + userID, threadIDs, ) return err } @@ -1181,6 +1268,11 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode // the rail numbers line up with the rows the user sees. Empty // thread_ids fall back to row id so unthreaded mail counts as its // own conversation. + // + // Archived mail counts for All mail and nowhere else, so every other + // counter is computed over the thread's unarchived messages only. A rail + // number that counts rows its own view then hides is what made Archive + // look like it had done nothing. err := r.db.QueryRow(ctx, ` WITH ue AS ( SELECT @@ -1188,6 +1280,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode e.from_addr, e.internal_date, e.seen, + e.folder = 'archive' AS is_archived, EXISTS ( SELECT 1 FROM unibox_snoozes s WHERE s.user_id = e.user_id @@ -1196,36 +1289,33 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode ) AS is_snoozed FROM unibox_emails e WHERE e.email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1) - AND e.folder NOT IN ('spam', 'trash') + AND e.folder NOT IN `+foldersOutsideAllMail+` ), threads AS ( SELECT tkey, - bool_or(is_snoozed) AS is_snoozed, - bool_or(NOT seen) AS has_unread, - max(internal_date) AS last_date + bool_or(is_snoozed) AS is_snoozed, + bool_or(NOT is_archived) AS working, + bool_or(NOT seen) FILTER (WHERE NOT is_archived) AS has_unread, + max(internal_date) FILTER (WHERE NOT is_archived) AS last_date FROM ue GROUP BY tkey ), latest_per_thread AS ( SELECT DISTINCT ON (tkey) tkey, from_addr FROM ue - WHERE NOT is_snoozed + WHERE NOT is_snoozed AND NOT is_archived ORDER BY tkey, internal_date DESC - ), - user_mailbox_emails AS ( - SELECT email FROM email_accounts WHERE organization_id = $1 ) SELECT - COUNT(*) FILTER (WHERE NOT t.is_snoozed) AS total, - COUNT(*) FILTER (WHERE NOT t.is_snoozed AND t.has_unread) AS unread, - COUNT(*) FILTER (WHERE NOT t.is_snoozed AND t.last_date >= $2) AS today, - COUNT(*) FILTER (WHERE NOT t.is_snoozed AND t.last_date >= $3) AS week, - COUNT(*) FILTER (WHERE t.is_snoozed) AS snoozed, - (SELECT COUNT(*) FROM latest_per_thread l - WHERE EXISTS (SELECT 1 FROM user_mailbox_emails u WHERE u.email = ANY(l.from_addr))) AS awaiting, + COUNT(*) FILTER (WHERE NOT t.is_snoozed) AS total, + COUNT(*) FILTER (WHERE NOT t.is_snoozed AND t.working AND t.has_unread) AS unread, + COUNT(*) FILTER (WHERE NOT t.is_snoozed AND t.working AND t.last_date >= $2) AS today, + COUNT(*) FILTER (WHERE NOT t.is_snoozed AND t.working AND t.last_date >= $3) AS week, + COUNT(*) FILTER (WHERE t.is_snoozed AND t.working) AS snoozed, + (SELECT COUNT(*) FROM latest_per_thread l WHERE `+ownAddressSQL("l.from_addr", "$1")+`) AS awaiting, (SELECT COUNT(*) FROM ai_thread_drafts d - WHERE d.organization_id = $1 AND d.status = 'pending') AS awaiting_agent_draft + WHERE d.organization_id = $1 AND d.status = 'pending') AS awaiting_agent_draft FROM threads t `, orgID, todayStart, weekStart).Scan( &overview.Total, @@ -1299,7 +1389,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode )) AS total FROM email_accounts ea LEFT JOIN unibox_emails ue ON ue.email_id = ea.id AND ue.user_id = ea.user_id - AND ue.folder NOT IN ('spam', 'trash') + AND ue.folder NOT IN `+foldersOutsideWorkingViews+` WHERE ea.organization_id = $1 GROUP BY ea.id, ea.email, ea.name ORDER BY ea.email ASC @@ -1342,7 +1432,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode LEFT JOIN email_tags et ON et.tag_id = t.id LEFT JOIN email_accounts ea ON ea.id = et.email_id AND ea.organization_id = t.organization_id LEFT JOIN unibox_emails ue ON ue.email_id = ea.id - AND ue.folder NOT IN ('spam', 'trash') + AND ue.folder NOT IN `+foldersOutsideWorkingViews+` WHERE t.organization_id = $1 GROUP BY t.id, t.title, t.color, t.position ORDER BY t.position ASC, t.title ASC @@ -1375,7 +1465,7 @@ func (r *uniboxRepository) Overview(ctx context.Context, orgID uuid.UUID) (*mode SELECT e.thread_id, bool_or(NOT e.seen) AS has_unread FROM unibox_emails e WHERE e.email_id IN (SELECT id FROM email_accounts WHERE organization_id = $1) - AND e.folder NOT IN ('spam', 'trash') + AND e.folder NOT IN `+foldersOutsideWorkingViews+` AND NOT EXISTS ( SELECT 1 FROM unibox_snoozes s WHERE s.user_id = e.user_id AND s.thread_id = e.thread_id AND s.snoozed_until > NOW() diff --git a/internal/repository/unibox_scope_live_test.go b/internal/repository/unibox_scope_live_test.go new file mode 100644 index 000000000..44e1befa7 --- /dev/null +++ b/internal/repository/unibox_scope_live_test.go @@ -0,0 +1,341 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/warmbly/warmbly/internal/models" +) + +// What a view shows after a conversation is filed, and who "we spoke last" +// resolves to. Both are decided entirely in SQL, which is why they run against +// a real schema: +// +// WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \ +// go test ./internal/repository/ -run LiveUniboxScope -v +// +// Archive used to leave a conversation in every view but Inbox, so pressing it +// from Awaiting reply removed the row and the next read put it straight back. +// Awaiting reply compared a mailbox address against the whole From header, +// which is "Name " from every provider, so it matched almost nothing. + +// message writes one message into the fixture's mailbox with the from header +// and folder the case needs. threadID groups them into a conversation. +func (f *uniboxFolderFixture) scopedMessage(t *testing.T, repo UniboxRepository, threadID, from, folder string, at time.Time) uuid.UUID { + t.Helper() + id := uuid.New() + err := repo.CreateEntry(context.Background(), f.user, &models.EmailMessageStoreData{ + ID: id, EmailID: f.mailbox, Folder: folder, + ThreadID: threadID, MessageID: "<" + id.String() + "@test.local>", + FromAddr: []string{from}, + ToAddr: []string{"them@example.com"}, + Subject: "Scope", Snippet: "Scope", + InternalDate: at, SentDate: at, CreatedAt: at, UpdatedAt: at, + Seen: false, + }) + if err != nil { + t.Fatalf("CreateEntry: %v", err) + } + return id +} + +func (f *uniboxFolderFixture) mailboxAddress(t *testing.T, repo UniboxRepository) string { + t.Helper() + ov, err := repo.Overview(context.Background(), f.org) + if err != nil { + t.Fatalf("Overview: %v", err) + } + if len(ov.Mailboxes) != 1 { + t.Fatalf("fixture has %d mailboxes, want 1", len(ov.Mailboxes)) + } + return ov.Mailboxes[0].Email +} + +func threadIDs(res *models.MailSearchResult) []string { + out := make([]string, 0, len(res.Data)) + for _, row := range res.Data { + out = append(out, row.ThreadID) + } + return out +} + +func listsThread(res *models.MailSearchResult, want string) bool { + for _, id := range threadIDs(res) { + if id == want { + return true + } + } + return false +} + +// A mailbox sends as "Name ". Comparing that against the address whole +// is what made Awaiting reply look empty on a workspace that had been sending +// all week. +func TestLiveUniboxScopeAwaitingReplyReadsTheAddressOutOfTheHeader(t *testing.T) { + handle := liveUniboxFolderDB(t) + f := newUniboxFolderFixture(t, handle.Pool) + repo := NewUniboxRepository(handle) + ctx := context.Background() + ours := f.mailboxAddress(t, repo) + now := time.Now().UTC() + + f.scopedMessage(t, repo, "thread-wrapped", "Alex at Acme <"+ours+">", models.FolderSent, now) + f.scopedMessage(t, repo, "thread-theirs", "them@example.com", models.FolderInbox, now) + + awaiting := true + res, err := repo.Search(ctx, f.org, &models.MailSearchParams{AwaitingReply: &awaiting, PageSize: 50}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if !listsThread(res, "thread-wrapped") { + t.Fatalf("awaiting reply = %v, want the thread we spoke last on", threadIDs(res)) + } + if listsThread(res, "thread-theirs") { + t.Fatalf("awaiting reply = %v, want no thread the other side spoke last on", threadIDs(res)) + } + + ov, err := repo.Overview(ctx, f.org) + if err != nil { + t.Fatalf("Overview: %v", err) + } + if ov.AwaitingReply != 1 { + t.Fatalf("overview awaiting_reply = %d, want 1 (the rail has to agree with the rows)", ov.AwaitingReply) + } +} + +// An address that merely contains ours is not ours. +func TestLiveUniboxScopeAwaitingReplyIsNotASubstringMatch(t *testing.T) { + handle := liveUniboxFolderDB(t) + f := newUniboxFolderFixture(t, handle.Pool) + repo := NewUniboxRepository(handle) + ctx := context.Background() + ours := f.mailboxAddress(t, repo) + + f.scopedMessage(t, repo, "thread-lookalike", "Impostor ", models.FolderInbox, time.Now().UTC()) + + awaiting := true + res, err := repo.Search(ctx, f.org, &models.MailSearchParams{AwaitingReply: &awaiting, PageSize: 50}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if listsThread(res, "thread-lookalike") { + t.Fatalf("awaiting reply = %v, want an address that only contains ours left out", threadIDs(res)) + } +} + +// Filing takes the conversation out of every view a workspace works from, and +// leaves it in All mail and in Archive. Anything less and the row comes back on +// the next read, which is what "Archive does nothing" looked like. +func TestLiveUniboxScopeArchiveLeavesTheWorkingViews(t *testing.T) { + handle := liveUniboxFolderDB(t) + f := newUniboxFolderFixture(t, handle.Pool) + repo := NewUniboxRepository(handle) + ctx := context.Background() + ours := f.mailboxAddress(t, repo) + now := time.Now().UTC() + + f.scopedMessage(t, repo, "thread-filed", "Alex at Acme <"+ours+">", models.FolderSent, now) + + awaiting, unseen, yes := true, true, true + before, err := repo.Search(ctx, f.org, &models.MailSearchParams{AwaitingReply: &awaiting, PageSize: 50}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if !listsThread(before, "thread-filed") { + t.Fatal("the conversation is not in Awaiting reply to begin with") + } + + if err := repo.MoveThreadsToFolder(ctx, f.org, []string{"thread-filed"}, models.FolderArchive); err != nil { + t.Fatalf("MoveThreadsToFolder: %v", err) + } + + for _, tc := range []struct { + view string + params *models.MailSearchParams + }{ + {"unscoped", &models.MailSearchParams{PageSize: 50}}, + {"awaiting reply", &models.MailSearchParams{AwaitingReply: &awaiting, PageSize: 50}}, + {"unread", &models.MailSearchParams{Unseen: &unseen, PageSize: 50}}, + } { + res, err := repo.Search(ctx, f.org, tc.params) + if err != nil { + t.Fatalf("Search %s: %v", tc.view, err) + } + if listsThread(res, "thread-filed") { + t.Errorf("a filed conversation is still in %s", tc.view) + } + } + + allMail, err := repo.Search(ctx, f.org, &models.MailSearchParams{IncludeArchived: &yes, PageSize: 50}) + if err != nil { + t.Fatalf("Search all mail: %v", err) + } + if !listsThread(allMail, "thread-filed") { + t.Error("a filed conversation has left All mail too; Archive is where it went, not nowhere") + } + + archive := models.FolderArchive + filed, err := repo.Search(ctx, f.org, &models.MailSearchParams{Folder: &archive, PageSize: 50}) + if err != nil { + t.Fatalf("Search archive: %v", err) + } + if !listsThread(filed, "thread-filed") { + t.Error("a filed conversation is not in the Archive folder") + } + + ov, err := repo.Overview(ctx, f.org) + if err != nil { + t.Fatalf("Overview: %v", err) + } + if ov.AwaitingReply != 0 || ov.Unread != 0 { + t.Errorf("overview awaiting=%d unread=%d, want both 0 once the only conversation is filed", ov.AwaitingReply, ov.Unread) + } + if ov.Total != 1 { + t.Errorf("overview total = %d, want 1: All mail still holds it", ov.Total) + } + unseenCount, err := repo.GetUnseenCount(ctx, f.org, nil) + if err != nil { + t.Fatalf("GetUnseenCount: %v", err) + } + if unseenCount != 0 { + t.Errorf("unread badge = %d, want 0", unseenCount) + } +} + +// Filing by conversation has to take every message in it. Leaving one behind +// puts the whole row back, because a thread is shown by any message of it that +// is still in a working folder. +func TestLiveUniboxScopeArchiveTakesTheWholeConversation(t *testing.T) { + handle := liveUniboxFolderDB(t) + f := newUniboxFolderFixture(t, handle.Pool) + repo := NewUniboxRepository(handle) + ctx := context.Background() + now := time.Now().UTC() + + f.scopedMessage(t, repo, "thread-pair", "them@example.com", models.FolderInbox, now.Add(-time.Hour)) + f.scopedMessage(t, repo, "thread-pair", "them@example.com", models.FolderInbox, now) + + if err := repo.MoveThreadsToFolder(ctx, f.org, []string{"thread-pair"}, models.FolderArchive); err != nil { + t.Fatalf("MoveThreadsToFolder: %v", err) + } + + res, err := repo.Search(ctx, f.org, &models.MailSearchParams{PageSize: 50}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if listsThread(res, "thread-pair") { + t.Fatal("filing the conversation left part of it behind, so the row is still listed") + } +} + +// Another organization's conversations are not this one's to file. +func TestLiveUniboxScopeMoveThreadsIsOrgScoped(t *testing.T) { + handle := liveUniboxFolderDB(t) + mine := newUniboxFolderFixture(t, handle.Pool) + theirs := newUniboxFolderFixture(t, handle.Pool) + repo := NewUniboxRepository(handle) + ctx := context.Background() + + id := theirs.scopedMessage(t, repo, "thread-theirs-only", "them@example.com", models.FolderInbox, time.Now().UTC()) + if err := repo.MoveThreadsToFolder(ctx, mine.org, []string{"thread-theirs-only"}, models.FolderArchive); err != nil { + t.Fatalf("MoveThreadsToFolder: %v", err) + } + + got, err := repo.GetByID(ctx, theirs.user, id) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.Folder != models.FolderInbox { + t.Fatalf("folder = %q, want another org's conversation left at %q", got.Folder, models.FolderInbox) + } +} + +// Marking a conversation read from a list row addresses it by thread, because +// the row knows the conversation and not the ids inside it. +func TestLiveUniboxScopeMarkSeenByThreadsTakesTheWholeConversation(t *testing.T) { + handle := liveUniboxFolderDB(t) + f := newUniboxFolderFixture(t, handle.Pool) + repo := NewUniboxRepository(handle) + ctx := context.Background() + now := time.Now().UTC() + + f.scopedMessage(t, repo, "thread-unread", "them@example.com", models.FolderInbox, now.Add(-time.Hour)) + f.scopedMessage(t, repo, "thread-unread", "them@example.com", models.FolderInbox, now) + + changed, err := repo.MarkSeenByThreads(ctx, f.org, []string{"thread-unread"}, true) + if err != nil { + t.Fatalf("MarkSeenByThreads: %v", err) + } + if len(changed) != 2 { + t.Fatalf("changed %d messages, want both in the conversation", len(changed)) + } + + count, err := repo.GetUnseenCount(ctx, f.org, nil) + if err != nil { + t.Fatalf("GetUnseenCount: %v", err) + } + if count != 0 { + t.Fatalf("unread = %d, want 0", count) + } + + // Already read costs nothing at the provider: only real changes relay. + again, err := repo.MarkSeenByThreads(ctx, f.org, []string{"thread-unread"}, true) + if err != nil { + t.Fatalf("MarkSeenByThreads again: %v", err) + } + if len(again) != 0 { + t.Fatalf("changed %d messages on a no-op mark, want 0", len(again)) + } +} + +// The selection bar snoozes a whole screenful in one call, so the repository +// takes the set. Upsert semantics hold per row: re-snoozing a conversation +// already in the set moves its time rather than failing the batch. +func TestLiveUniboxScopeSnoozeTakesASet(t *testing.T) { + handle := liveUniboxFolderDB(t) + f := newUniboxFolderFixture(t, handle.Pool) + repo := NewUniboxRepository(handle) + ctx := context.Background() + now := time.Now().UTC() + + f.scopedMessage(t, repo, "thread-a", "them@example.com", models.FolderInbox, now) + f.scopedMessage(t, repo, "thread-b", "them@example.com", models.FolderInbox, now) + t.Cleanup(func() { + _ = repo.DeleteSnoozes(context.Background(), f.user, []string{"thread-a", "thread-b"}) + }) + + rows, err := repo.UpsertSnoozes(ctx, f.user, []string{"thread-a", "thread-b"}, now.Add(time.Hour)) + if err != nil { + t.Fatalf("UpsertSnoozes: %v", err) + } + if len(rows) != 2 { + t.Fatalf("snoozed %d conversations, want 2", len(rows)) + } + + res, err := repo.Search(ctx, f.org, &models.MailSearchParams{PageSize: 50}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if listsThread(res, "thread-a") || listsThread(res, "thread-b") { + t.Fatalf("a snoozed conversation is still listed: %v", threadIDs(res)) + } + + // A second pass over the same set moves the time instead of erroring. + if _, err := repo.UpsertSnoozes(ctx, f.user, []string{"thread-a", "thread-b"}, now.Add(2*time.Hour)); err != nil { + t.Fatalf("UpsertSnoozes again: %v", err) + } + + if err := repo.DeleteSnoozes(ctx, f.user, []string{"thread-a", "thread-b"}); err != nil { + t.Fatalf("DeleteSnoozes: %v", err) + } + back, err := repo.Search(ctx, f.org, &models.MailSearchParams{PageSize: 50}) + if err != nil { + t.Fatalf("Search after un-snooze: %v", err) + } + if !listsThread(back, "thread-a") || !listsThread(back, "thread-b") { + t.Fatalf("un-snoozing did not bring the conversations back: %v", threadIDs(back)) + } +} diff --git a/web/src/app/app/unibox/page.tsx b/web/src/app/app/unibox/page.tsx index d1eba967e..e199a968d 100644 --- a/web/src/app/app/unibox/page.tsx +++ b/web/src/app/app/unibox/page.tsx @@ -301,6 +301,12 @@ export default function UniboxPage() { // mailbox. next.categoryIds = [scope.categoryId]; break; + case "all": + // All mail is the one view a filed conversation stays in, so + // Archive means "out of the way", not "gone". Every other scope + // leaves it out, which is what makes the button do something. + next.includeArchived = true; + break; case "view": { // A premade view is a set of automatic labels, resolved to the // workspace's category rows by slug. diff --git a/web/src/app/app/unibox/uniboxKeyboard.test.tsx b/web/src/app/app/unibox/uniboxKeyboard.test.tsx index 20c012b2a..abede95e9 100644 --- a/web/src/app/app/unibox/uniboxKeyboard.test.tsx +++ b/web/src/app/app/unibox/uniboxKeyboard.test.tsx @@ -167,7 +167,7 @@ describe("unibox list shortcuts (#484)", SUITE, () => { ).toBe(false); await act(async () => { - fireEvent.click(screen.getByText(ROWS[0].subject).closest("button")!); + fireEvent.click(screen.getByText(ROWS[0].subject).closest('[role="button"]')!); }); await settle(); diff --git a/web/src/app/app/unibox/uniboxLayout.test.tsx b/web/src/app/app/unibox/uniboxLayout.test.tsx index cfd2f6f4e..345ed6260 100644 --- a/web/src/app/app/unibox/uniboxLayout.test.tsx +++ b/web/src/app/app/unibox/uniboxLayout.test.tsx @@ -103,7 +103,7 @@ async function drag(fromX: number, toX: number) { async function openThread(subject: string) { await act(async () => { - fireEvent.click(screen.getByText(subject).closest("button")!); + fireEvent.click(screen.getByText(subject).closest('[role="button"]')!); }); await settle(); } diff --git a/web/src/app/app/unibox/uniboxScroll.test.tsx b/web/src/app/app/unibox/uniboxScroll.test.tsx index 749ce9f0b..2887e3d56 100644 --- a/web/src/app/app/unibox/uniboxScroll.test.tsx +++ b/web/src/app/app/unibox/uniboxScroll.test.tsx @@ -87,7 +87,7 @@ describe("unibox scroll position", SUITE, () => { await scrollTo(1200); await act(async () => { - fireEvent.click(screen.getByText("Subject 4").closest("button")!); + fireEvent.click(screen.getByText("Subject 4").closest('[role="button"]')!); }); await settle(); @@ -108,7 +108,7 @@ describe("unibox scroll position", SUITE, () => { await settle(); await act(async () => { - fireEvent.click(screen.getByText("Subject 2").closest("button")!); + fireEvent.click(screen.getByText("Subject 2").closest('[role="button"]')!); }); await settle(); @@ -126,7 +126,7 @@ describe("unibox scroll position", SUITE, () => { await scrollTo(700); await act(async () => { - fireEvent.click(screen.getByText("Subject 3").closest("button")!); + fireEvent.click(screen.getByText("Subject 3").closest('[role="button"]')!); }); await settle(); scroller().scrollTop = 0; diff --git a/web/src/app/app/unibox/uniboxTriage.test.tsx b/web/src/app/app/unibox/uniboxTriage.test.tsx new file mode 100644 index 000000000..b71a9d094 --- /dev/null +++ b/web/src/app/app/unibox/uniboxTriage.test.tsx @@ -0,0 +1,217 @@ +// Triage from the conversation list: the row's own actions, and the selection +// bar behind the checkboxes. +// +// Archive used to look like it had done nothing. The row left the list on the +// optimistic removal and the refetch that followed put it straight back, +// because every scope but Inbox listed archived mail too. The server half of +// that is pinned in Go; this pins the client half: the scopes that must ask +// for filed conversations back, and the ones that must not. + +import React from "react"; +import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest"; +import { screen, act, fireEvent, waitFor, within } from "@testing-library/react"; +import { + installLayoutShims, + mount, + resetScrollTops, + ROWS, + setViewportWidth, + settle, + SUITE, +} from "./uniboxHarness"; + +type Call = { method?: string; url?: string; data?: Record }; + +const calls = vi.hoisted((): Call[] => []); + +beforeAll(() => { + installLayoutShims(); + setViewportWidth(1512); +}); + +vi.mock("@/lib/api/client/Request", () => ({ + default: async (cfg: Call) => { + calls.push({ method: cfg.method, url: cfg.url, data: cfg.data }); + const { route } = await import("./uniboxHarness"); + return route(String(cfg?.url ?? "")); + }, +})); +vi.mock("@/lib/helper/getToken", () => ({ + default: () => ({ + access_token: "a", + refresh_token: "r", + access_token_expires_at: new Date(Date.now() + 3600e3).toISOString(), + refresh_token_expires_at: new Date(Date.now() + 3600e3).toISOString(), + }), +})); +vi.mock("@/hooks/SocketProvider", () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock("@/hooks/context/socket", async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + useSocket: () => ({ + isConnected: false, + subscribeToChannel: () => () => {}, + pushToChannel: () => {}, + socket: null, + status: "closed", + }), + useChannel: () => ({ state: "closed", push: () => {}, channel: null }), + useChannelEvent: () => {}, + useChannelSubscription: () => {}, + }; +}); + +const listUrls = () => + calls + .filter((c) => c.url === "/unibox" || c.url?.startsWith("/unibox?")) + .map((c) => String(c.url)); + +const patches = (path: string) => + calls.filter((c) => c.method === "PATCH" && c.url === path); + +// The rows carry Archive buttons of their own, so the bar's controls are +// always reached through the bar. +const selectionBar = () => + screen.getByRole("toolbar", { name: "Selection actions" }); + +describe("unibox triage", SUITE, () => { + beforeEach(() => { + calls.length = 0; + resetScrollTops(); + setViewportWidth(1512); + }); + + // All mail is the one view a filed conversation stays in. + it("asks for filed conversations in All mail", async () => { + await mount("/app/unibox/all"); + await settle(); + expect(listUrls().some((u) => u.includes("include_archived=true"))).toBe(true); + }); + + // Every other scope has to leave them out, or Archive removes a row that + // the next read returns. + it("leaves filed conversations out of a working view", async () => { + await mount("/app/unibox/awaiting"); + await settle(); + const awaiting = listUrls(); + expect(awaiting.some((u) => u.includes("awaiting_reply=true"))).toBe(true); + expect(awaiting.some((u) => u.includes("include_archived"))).toBe(false); + }); + + // The row knows its conversation and not the message ids inside it, so it + // files by thread; filing part of one leaves the row where it was. + it("archives from the row, addressing the whole conversation", async () => { + await mount("/app/unibox/awaiting"); + await settle(); + + const row = screen.getByText(ROWS[0].subject).closest('[role="button"]')!; + await act(async () => { + fireEvent.click(row.querySelector('[aria-label="Archive"]')!); + }); + await settle(); + + const [filed] = patches("/unibox/folder"); + expect(filed).toBeTruthy(); + expect(filed.data).toMatchObject({ + folder: "archive", + thread_ids: [ROWS[0].thread_id], + }); + }); + + // Opening a conversation is not what the row's own actions are for. + it("does not open the conversation when a row action is used", async () => { + const router = await mount("/app/unibox/awaiting"); + await settle(); + + const row = screen.getByText(ROWS[1].subject).closest('[role="button"]')!; + await act(async () => { + fireEvent.click(row.querySelector('[aria-label="Conversation actions"]')!); + }); + await settle(); + + expect(router.state.location.pathname).toBe("/app/unibox/awaiting"); + expect(screen.getByText("Mark as unread")).toBeTruthy(); + }); + + it("marks a whole selection read from the selection bar", async () => { + await mount("/app/unibox/awaiting"); + await settle(); + + for (const i of [0, 1]) { + const row = screen.getByText(ROWS[i].subject).closest('[role="button"]')!; + await act(async () => { + fireEvent.click(row.querySelector('input[type="checkbox"]')!); + }); + } + await settle(); + + await waitFor(() => expect(screen.getByText("2 selected")).toBeTruthy()); + + calls.length = 0; + await act(async () => { + fireEvent.click(within(selectionBar()).getByTitle("Mark read")); + }); + await settle(); + + const [seen] = patches("/unibox/seen"); + expect(seen).toBeTruthy(); + expect(seen.data).toMatchObject({ + seen: true, + thread_ids: [ROWS[0].thread_id, ROWS[1].thread_id], + }); + // The rows the bar acted on are no longer ticked: a count that outlives + // what it applied to is a lie. + await waitFor(() => expect(screen.queryByText("2 selected")).toBeNull()); + }); + + it("archives the whole selection in one call", async () => { + await mount("/app/unibox/awaiting"); + await settle(); + + for (const i of [0, 1, 2]) { + const row = screen.getByText(ROWS[i].subject).closest('[role="button"]')!; + await act(async () => { + fireEvent.click(row.querySelector('input[type="checkbox"]')!); + }); + } + await settle(); + + calls.length = 0; + await act(async () => { + fireEvent.click(within(selectionBar()).getByTitle("Archive")); + }); + await settle(); + + const filed = patches("/unibox/folder"); + expect(filed).toHaveLength(1); + expect(filed[0].data).toMatchObject({ + folder: "archive", + thread_ids: [ROWS[0].thread_id, ROWS[1].thread_id, ROWS[2].thread_id], + }); + }); + + // Shift picks the run between the two, the way a file list does. + it("extends a selection with shift", async () => { + await mount("/app/unibox/awaiting"); + await settle(); + + const box = (i: number) => + screen + .getByText(ROWS[i].subject) + .closest('[role="button"]')! + .querySelector('input[type="checkbox"]')!; + + await act(async () => { + fireEvent.click(box(0)); + }); + await act(async () => { + fireEvent.click(box(3), { shiftKey: true }); + }); + await settle(); + + await waitFor(() => expect(screen.getByText("4 selected")).toBeTruthy()); + }); +}); diff --git a/web/src/components/app/unibox/ConversationItem.tsx b/web/src/components/app/unibox/ConversationItem.tsx index 7de5a57b6..a2fc51d9b 100644 --- a/web/src/components/app/unibox/ConversationItem.tsx +++ b/web/src/components/app/unibox/ConversationItem.tsx @@ -6,10 +6,38 @@ // the room without a coloured bar. Labels sit at the end of the subject line // as small tinted chips; the owning mailbox shows only when the workspace // has more than one, quietly at the end of the preview. +// +// Triage happens on the row: Archive and a three-dot menu sit where the +// timestamp is, so clearing a conversation never means opening it first. The +// row is a div rather than a button because those controls nest inside it and +// nested buttons are invalid HTML; each of them stops propagation so acting on +// a row never also opens it. + +import React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { + ArchiveIcon, + InboxIcon, + MailCheckIcon, + MailOpenIcon, + MoonIcon, + MoreHorizontalIcon, + TrashIcon, +} from "lucide-react"; import type UniboxEmail from "@/lib/api/models/app/unibox/UniboxEmail"; import { useAppStore } from "@/stores"; import { useResourceViewers } from "@/hooks/PresenceProvider"; +import type { ConversationActions } from "@/hooks/useConversationActions"; +import { SNOOZE_PRESETS } from "@/lib/unibox/snooze"; +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuLabel, + PopoverMenuSeparator, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; import { cn } from "@/lib/utils"; import { nameFromAddr } from "@/lib/helper/emailAddress"; @@ -32,9 +60,35 @@ function fromName(s: string): string { interface ConversationItemProps { email: UniboxEmail; + /** + * The scope the row is being listed in. Archive and Trash offer the way + * back instead of the way out, and Trash has nowhere further to file to. + */ + scope?: string; + /** Ticked. */ + selected?: boolean; + /** + * Show the checkbox regardless of hover. The list turns this on while + * anything is selected, and on touch, where there is no hover to reveal it. + */ + selecting?: boolean; + /** Shift extends from the last row ticked, the way a file list does. */ + onToggleSelect?: (threadId: string, next: boolean, extend: boolean) => void; + /** + * Filing, read state and snooze. Passed in rather than taken from the hook + * here so a long list holds one set of mutations, not one per row. + */ + actions: ConversationActions; } -export function ConversationItem({ email }: ConversationItemProps) { +export function ConversationItem({ + email, + scope, + selected = false, + selecting = false, + onToggleSelect, + actions, +}: ConversationItemProps) { const selectedThreadId = useAppStore((s) => s.selectedThreadId); const setSelectedThreadId = useAppStore((s) => s.setSelectedThreadId); const setSelectedAccountId = useAppStore((s) => s.setSelectedAccountId); @@ -62,22 +116,75 @@ export function ConversationItem({ email }: ConversationItemProps) { const viewers = useResourceViewers(`thread:${threadId}`); const replierName = viewers.find((v) => v.action === "replying")?.name; + const open = () => { + setSelectedThreadId(threadId); + setSelectedAccountId(email.account_id ?? null); + }; + + + return ( - + + + + {snoozeMode ? ( + + Snooze until + {SNOOZE_PRESETS.map((p) => ( + actions.snooze([threadId], p.until())} + > + {p.label} + + ))} + + setSnoozeMode(false)} + closeOnSelect={false} + > + Back + + + ) : ( + + + ) : ( + + ) + } + onSelect={() => actions.setSeen([threadId], unread)} + > + {unread ? "Mark as read" : "Mark as unread"} + + {snoozedScope ? ( + } + onSelect={() => actions.unsnooze([threadId])} + > + Un-snooze now + + ) : ( + } + onSelect={() => setSnoozeMode(true)} + closeOnSelect={false} + > + Snooze… + + )} + + {filed ? ( + } + disabled={actions.filing} + onSelect={() => actions.file([threadId], "inbox")} + > + Move to inbox + + ) : ( + } + disabled={actions.filing} + onSelect={() => actions.file([threadId], "archive")} + > + Archive + + )} + {scope !== "trash" && ( + } + disabled={actions.filing} + onSelect={() => actions.file([threadId], "trash")} + > + Delete + + )} + + )} + + + + + ); +} + +function RowButton({ + label, + onClick, + disabled, + className, + children, +}: { + label: string; + onClick: () => void; + disabled?: boolean; + className?: string; + children: React.ReactNode; +}) { + return ( + ); } diff --git a/web/src/components/app/unibox/ConversationList.tsx b/web/src/components/app/unibox/ConversationList.tsx index 88ae320cf..512b9ce77 100644 --- a/web/src/components/app/unibox/ConversationList.tsx +++ b/web/src/components/app/unibox/ConversationList.tsx @@ -13,6 +13,8 @@ import React from "react"; import { AnimatePresence, motion } from "framer-motion"; import { PanelLeftIcon, PenLineIcon, SearchIcon } from "lucide-react"; import { ConversationItem } from "./ConversationItem"; +import { SelectionBar } from "./SelectionBar"; +import { useConversationActions } from "@/hooks/useConversationActions"; import useUniboxSearch from "@/lib/api/hooks/app/unibox/useUniboxSearch"; import { useShortcutActions } from "@/hooks/useShortcutActions"; import useDebouncedValue from "@/hooks/useDebouncedValue"; @@ -112,6 +114,84 @@ export function ConversationList({ const totalShown = emails.length; const activeFilters = countUserFilters(params, baseParams); + // ── Multi-select ─────────────────────────────────────────────── + // Thread ids, not row indexes: the list re-orders under a refetch, and an + // index would then name a different conversation than the one ticked. + const actions = useConversationActions(); + const [picked, setPicked] = React.useState>( + () => new Set(), + ); + // `selectMode` is the touch entry point, where there is no hover to reveal a + // checkbox with. It stays on after the last row is unticked, so unticking + // one by mistake does not take every box off screen. + const [selectMode, setSelectMode] = React.useState(false); + // A new scope is a new set of rows; carrying a selection across would apply + // an action to conversations the user can no longer see. + const [pickedScope, setPickedScope] = React.useState(scopeKey); + if (pickedScope !== scopeKey) { + setPickedScope(scopeKey); + setPicked(new Set()); + setSelectMode(false); + } + + const rowKey = React.useCallback( + (row: (typeof emails)[number]) => row.thread_id || row.id, + [], + ); + // What the bar acts on: only rows still on screen. A conversation that has + // left the list (filed by a teammate, snoozed, filtered out) must not be + // counted, or the bar promises an action on something nobody can see. + const selectedIds = React.useMemo( + () => emails.map(rowKey).filter((id) => picked.has(id)), + [emails, picked, rowKey], + ); + const allSelected = emails.length > 0 && selectedIds.length === emails.length; + const selecting = selectMode || selectedIds.length > 0; + const clearSelection = React.useCallback(() => { + setPicked(new Set()); + setSelectMode(false); + }, []); + + // Shift extends from the last row ticked, the way a file list does. + const lastPicked = React.useRef(null); + const toggleSelect = React.useCallback( + (threadId: string, next: boolean, extend: boolean) => { + // Read the anchor before the updater, not inside it. React may defer an + // updater to render time, by which point the assignment below has + // already moved the anchor onto the row being clicked, and a shift-click + // would extend a range from a row to itself. + const anchor = lastPicked.current; + setPicked((prev) => { + const out = new Set(prev); + const from = anchor ? emails.findIndex((r) => rowKey(r) === anchor) : -1; + const to = emails.findIndex((r) => rowKey(r) === threadId); + if (extend && from >= 0 && to >= 0) { + const [lo, hi] = from < to ? [from, to] : [to, from]; + for (let i = lo; i <= hi; i++) { + if (next) out.add(rowKey(emails[i])); + else out.delete(rowKey(emails[i])); + } + } else if (next) { + out.add(threadId); + } else { + out.delete(threadId); + } + return out; + }); + lastPicked.current = threadId; + }, + [emails, rowKey], + ); + + const toggleAll = React.useCallback(() => { + setPicked((prev) => { + const everything = emails.map(rowKey); + const all = everything.length > 0 && everything.every((id) => prev.has(id)); + return all ? new Set() : new Set(everything); + }); + lastPicked.current = null; + }, [emails, rowKey]); + // A search or filter change keeps the previous rows on screen while // the new ones load (placeholderData). That is the moment to show progress: // a bar along the top and the stale rows dimmed. Background refetches from @@ -229,10 +309,34 @@ export function ConversationList({ if (currentIndex() < 0) selectRow(emails[0]); }, listDeselect: () => { + // The ticks are the innermost thing Escape can clear: taking the open + // conversation away first would leave a selection bar with no obvious + // way to dismiss it. + if (selecting) { + clearSelection(); + return; + } if (!selectedThreadId) return; setSelectedThreadId(null); setSelectedAccountId(null); }, + listToggleSelect: () => { + const row = emails[currentIndex()]; + if (!row) return; + const id = rowKey(row); + toggleSelect(id, !picked.has(id), false); + }, + listArchive: () => { + // The ticked rows when there are any, otherwise the focused one. + const target = selectedIds.length > 0 ? selectedIds : []; + if (target.length === 0) { + const row = emails[currentIndex()]; + if (!row) return; + void actions.file([rowKey(row)], "archive"); + return; + } + void actions.file(target, "archive").finally(clearSelection); + }, focusSearch: () => searchRef.current?.focus(), }, // The filter popover owns the keyboard while it is open. @@ -240,6 +344,9 @@ export function ConversationList({ ); const filtering = activeFilters > 0 || !!search.trim(); + const rowScope = scopeKey.startsWith("folder:") + ? scopeKey.slice("folder:".length) + : scopeKey; return (
@@ -270,6 +377,29 @@ export function ConversationList({ )} + {/* Select is the touch entry point into multi-select; on a pointer + device hovering a row already shows its box, but the control is + kept everywhere so the feature is discoverable at all. */} + {selecting ? ( + + ) : ( + + )} +
); } diff --git a/web/src/components/app/unibox/SelectionBar.tsx b/web/src/components/app/unibox/SelectionBar.tsx new file mode 100644 index 000000000..70370dbc4 --- /dev/null +++ b/web/src/components/app/unibox/SelectionBar.tsx @@ -0,0 +1,181 @@ +// Floating bar for a multi-row selection in the conversation list. +// +// Same shape as the contacts table's: fixed to the bottom centre, the count on +// the left, the actions in the middle, Clear on the right. Fixed rather than +// absolute, so it cannot park itself below the fold of a long list. + +import React from "react"; +import { + ArchiveIcon, + CheckIcon, + InboxIcon, + Loader2Icon, + MailCheckIcon, + MailOpenIcon, + MoonIcon, + TrashIcon, +} from "lucide-react"; + +import { + PopoverMenu, + PopoverMenuContent, + PopoverMenuItem, + PopoverMenuLabel, + PopoverMenuTrigger, +} from "@/components/ui/popover-menu"; +import { SNOOZE_PRESETS } from "@/lib/unibox/snooze"; +import type { ConversationActions } from "@/hooks/useConversationActions"; +import { cn } from "@/lib/utils"; + +interface SelectionBarProps { + threadIds: string[]; + actions: ConversationActions; + /** The scope the rows are listed in, which decides the filing direction. */ + scope?: string; + onClear: () => void; +} + +export function SelectionBar({ + threadIds, + actions, + scope, + onClear, +}: SelectionBarProps) { + const count = threadIds.length; + // Every action clears the selection: the rows it applied to have left the + // list, and a count that outlives its rows is a lie. + const run = React.useCallback( + (fn: () => void | Promise) => { + void Promise.resolve(fn()).finally(onClear); + }, + [onClear], + ); + + if (count === 0) return null; + + const filed = scope === "archive" || scope === "trash"; + const snoozedScope = scope === "snoozed"; + + return ( +
+
+ + {count.toLocaleString()} selected +
+ + } + label="Mark read" + onClick={() => run(() => actions.setSeen(threadIds, true))} + /> + } + label="Mark unread" + onClick={() => run(() => actions.setSeen(threadIds, false))} + /> + + {snoozedScope ? ( + } + label="Un-snooze" + onClick={() => run(() => actions.unsnooze(threadIds))} + /> + ) : ( + + + + + + + Snooze {count.toLocaleString()} until + + {SNOOZE_PRESETS.map((p) => ( + run(() => actions.snooze(threadIds, p.until()))} + > + {p.label} + + ))} + + + )} + + + ) : ( + + ) + } + label={filed ? "Move to inbox" : "Archive"} + busy={actions.filing} + onClick={() => + run(() => actions.file(threadIds, filed ? "inbox" : "archive")) + } + /> + + {scope !== "trash" && ( + } + label="Delete" + danger + busy={actions.filing} + onClick={() => run(() => actions.file(threadIds, "trash"))} + /> + )} + +
+ +
+ ); +} + +function BarButton({ + icon, + label, + onClick, + busy, + danger, +}: { + icon: React.ReactNode; + label: string; + onClick: () => void; + busy?: boolean; + danger?: boolean; +}) { + return ( + + ); +} diff --git a/web/src/components/app/unibox/ThreadView.tsx b/web/src/components/app/unibox/ThreadView.tsx index 2f8a5e123..fa0a3b899 100644 --- a/web/src/components/app/unibox/ThreadView.tsx +++ b/web/src/components/app/unibox/ThreadView.tsx @@ -43,9 +43,9 @@ import ContactContextPanel from "./ContactContextPanel"; import { CategoryChip } from "@/components/app/contacts/CategoryPicker"; import useThread from "@/lib/api/hooks/app/unibox/useThread"; import useMarkSeen from "@/lib/api/hooks/app/unibox/useMarkSeen"; -import useMoveFolder from "@/lib/api/hooks/app/unibox/useMoveFolder"; -import { removeThreadsFromLists } from "@/lib/api/hooks/app/unibox/listCache"; -import moveFolderRequest, { type FilableFolder } from "@/lib/api/client/app/unibox/moveFolder"; +import { useConversationActions } from "@/hooks/useConversationActions"; +import { SNOOZE_PRESETS, offsetHours } from "@/lib/unibox/snooze"; +import type { FilableFolder } from "@/lib/api/client/app/unibox/moveFolder"; import { bareEmail, nameFromAddr, wrappedEmail } from "@/lib/helper/emailAddress"; import useThreadLabels from "@/lib/api/hooks/app/unibox/useThreadLabels"; import useThreadScheduled from "@/lib/api/hooks/app/unibox/useThreadScheduled"; @@ -64,10 +64,6 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { - snoozeThread, - unsnoozeThread, -} from "@/lib/api/client/app/unibox/snoozeThread"; import type UniboxEmail from "@/lib/api/models/app/unibox/UniboxEmail"; import type UniboxScheduledItem from "@/lib/api/models/app/unibox/UniboxScheduled"; import type { UniboxThreadMessage } from "@/lib/api/models/app/unibox/UniboxThread"; @@ -92,47 +88,6 @@ function toUniboxEmail(m: UniboxThreadMessage): UniboxEmail { }; } -// Filing copy, per destination. "Deleted" is deliberately not said anywhere: -// the message is moved to Trash here and still sits in the mail client. -const FILE_COPY: Record = { - archive: { done: "Archived", failed: "Couldn't archive" }, - trash: { done: "Moved to Trash", failed: "Couldn't move to Trash" }, - inbox: { done: "Moved to Inbox", failed: "Couldn't move to Inbox" }, -}; - -const SNOOZE_PRESETS: { label: string; until: () => Date }[] = [ - { label: "In 1 hour", until: () => offsetHours(1) }, - { label: "In 3 hours", until: () => offsetHours(3) }, - { label: "Tomorrow 9:00", until: () => atHour(1, 9) }, - { label: "Monday 9:00", until: () => nextMonday9() }, - { label: "Next week", until: () => offsetDays(7) }, -]; - -function offsetHours(h: number): Date { - const d = new Date(); - d.setHours(d.getHours() + h); - return d; -} -function offsetDays(d: number): Date { - const x = new Date(); - x.setDate(x.getDate() + d); - return x; -} -function atHour(dayOffset: number, hour: number): Date { - const d = new Date(); - d.setDate(d.getDate() + dayOffset); - d.setHours(hour, 0, 0, 0); - return d; -} -function nextMonday9(): Date { - const d = new Date(); - const dow = d.getDay(); - const delta = (1 - dow + 7) % 7 || 7; - d.setDate(d.getDate() + delta); - d.setHours(9, 0, 0, 0); - return d; -} - // Local datetime → ISO string. The native // hands back "YYYY-MM-DDTHH:mm" (no zone), interpreted as the user's // local clock : perfectly fine here since we round-trip to UTC on send. @@ -282,64 +237,29 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) { .filter((m) => !m.seen) .map((m) => m.id); if (unseenIds.length === 0) return; - markSeenMutate({ ids: unseenIds, threadId }); + markSeenMutate({ ids: unseenIds, threadIds: [threadId] }); }, [threadId, q.data, markSeenMutate]); // Header actions. Each one closes the thread: the effect above would // otherwise re-mark an "unread" thread as seen on the next refetch, and a // filed thread has left the list the reader is looking at. - const moveFolder = useMoveFolder(); + const actions = useConversationActions(); const setSelectedThreadId = useAppStore((s) => s.setSelectedThreadId); const threadIds = () => (q.data?.data ?? []).map((m) => m.id); const markUnread = () => { - markSeenMutate({ ids: threadIds(), seen: false, threadId }); + markSeenMutate({ ids: threadIds(), seen: false, threadIds: [threadId] }); setSelectedThreadId(null); }; - // One click and the conversation is gone from the list, so the way back - // belongs on screen; the Trash scope's Move to inbox is the slow path. This - // pane has already closed by the time Undo is clicked, so it calls the - // endpoint directly: react-query drops an unmounted observer's callbacks, - // and the invalidation is the whole point. - const offerUndo = (message: string, ids: string[]) => { - toast((t) => ( - - {message} - - - )); - }; - // Filing is store-side: the message keeps its place at the provider, and // the sync knows not to undo this (migration 000146). // The row leaves the list and the reader closes at once; the request runs // behind the toast, and a failure re-reads the list, which brings it back. + // The copy, the undo and the cache handling are shared with the list row. const fileThread = async (folder: FilableFolder) => { - const ids = threadIds(); - if (ids.length === 0 || moveFolder.isPending) return; - const copy = FILE_COPY[folder]; + if (actions.filing) return; setSelectedThreadId(null); - try { - await moveFolder.mutateAsync({ ids, folder, threadId }); - if (folder === "inbox") toast.success(copy.done); - else offerUndo(copy.done, ids); - } catch { - toast.error(copy.failed); - } + await actions.file([threadId], folder, threadIds()); }; // Restoring is only offered where the user can see what they are restoring. @@ -347,33 +267,16 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) { const filed = urlScope === "trash" || urlScope === "archive"; const snooze = useMutation({ - mutationFn: (until: Date) => - snoozeThread({ thread_id: threadId, snoozed_until: until.toISOString() }), - // Gone from the list the moment it is snoozed; the refetch confirms it. - onMutate: () => removeThreadsFromLists(queryClient, [threadId]), - onSuccess: () => { - toast.success("Snoozed"); - queryClient.invalidateQueries({ queryKey: ["unibox", "search"] }); - queryClient.invalidateQueries({ queryKey: ["unibox", "overview"] }); - queryClient.invalidateQueries({ queryKey: ["unibox", "unseen-count"] }); + mutationFn: (until: Date) => actions.snooze([threadId], until), + onSettled: () => { setSnoozeOpen(false); setCustomMode(false); }, - onError: () => { - toast.error("Couldn't snooze this thread"); - queryClient.invalidateQueries({ queryKey: ["unibox", "search"] }); - }, }); const unsnooze = useMutation({ - mutationFn: () => unsnoozeThread(threadId), - onSuccess: () => { - toast.success("Un-snoozed"); - queryClient.invalidateQueries({ queryKey: ["unibox", "search"] }); - queryClient.invalidateQueries({ queryKey: ["unibox", "overview"] }); - setSnoozeOpen(false); - }, - onError: () => toast.error("Couldn't un-snooze"), + mutationFn: () => actions.unsnooze([threadId]), + onSettled: () => setSnoozeOpen(false), }); // Built once per fetch, not once per render. Every consumer holds these @@ -595,14 +498,14 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) { } - disabled={moveFolder.isPending} + disabled={actions.filing} onClick={() => fileThread("inbox")} /> ) : ( } - disabled={moveFolder.isPending} + disabled={actions.filing} onClick={() => fileThread("archive")} /> )} @@ -611,7 +514,7 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) { label="Delete thread" danger icon={} - disabled={moveFolder.isPending} + disabled={actions.filing} onClick={() => fileThread("trash")} /> )} @@ -655,7 +558,7 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) { {filed ? ( } - disabled={moveFolder.isPending} + disabled={actions.filing} onSelect={() => fileThread("inbox")} > Move to inbox @@ -663,7 +566,7 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) { ) : ( } - disabled={moveFolder.isPending} + disabled={actions.filing} onSelect={() => fileThread("archive")} > Archive thread @@ -673,7 +576,7 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) { } - disabled={moveFolder.isPending} + disabled={actions.filing} onSelect={() => fileThread("trash")} > Delete thread diff --git a/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx b/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx index 9bfa74ef7..a03617e36 100644 --- a/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx +++ b/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx @@ -59,8 +59,10 @@ export default function ComposeHistoryPanel({ address, direction: tab === "sent" ? "sent" : undefined, query: search.trim() || undefined, - // History is reference material: include snoozed threads too. + // History is reference material: include snoozed and filed + // threads too. snoozed: "any", + includeArchived: true, }, `history:${address}:${tab}`, !!address, diff --git a/web/src/hooks/useConversationActions.tsx b/web/src/hooks/useConversationActions.tsx new file mode 100644 index 000000000..32a7688d5 --- /dev/null +++ b/web/src/hooks/useConversationActions.tsx @@ -0,0 +1,166 @@ +// The actions a conversation carries, wherever it is shown. +// +// The thread header, the list row and the selection bar offer the same set, so +// the copy, the undo toast and the cache handling live here once rather than +// three times. Everything takes a SET of thread ids: one row is the +// one-element case, and a selection is one round trip rather than one per row. +// +// Addressing by thread rather than by message id is deliberate. A list row +// knows its conversation and not the ids inside it, and filing part of a +// conversation leaves it in the view it was filed out of, which is exactly +// what "Archive does nothing" looked like. + +import React from "react"; +import toast from "react-hot-toast"; +import { useQueryClient } from "@tanstack/react-query"; + +import useMarkSeen from "@/lib/api/hooks/app/unibox/useMarkSeen"; +import useMoveFolder from "@/lib/api/hooks/app/unibox/useMoveFolder"; +import moveFolderRequest, { + type FilableFolder, +} from "@/lib/api/client/app/unibox/moveFolder"; +import { removeThreadsFromLists } from "@/lib/api/hooks/app/unibox/listCache"; +import { + snoozeThreads, + unsnoozeThreads, +} from "@/lib/api/client/app/unibox/snoozeThread"; +import { SNOOZE_MAX_MS } from "@/lib/unibox/snooze"; + +// Filing copy, per destination. "Deleted" is deliberately not said anywhere: +// the message is moved to Trash here and still sits in the mail client. +const FILE_COPY: Record = { + archive: { done: "Archived", failed: "Couldn't archive" }, + trash: { done: "Moved to Trash", failed: "Couldn't move to Trash" }, + inbox: { done: "Moved to Inbox", failed: "Couldn't move to Inbox" }, +}; + +function plural(n: number, one: string): string { + return n === 1 ? one : `${n.toLocaleString()} conversations`; +} + +export interface ConversationActions { + /** Archive / Trash / Move to inbox. Optional ids cover a loaded thread. */ + file: (threadIds: string[], folder: FilableFolder, ids?: string[]) => Promise; + setSeen: (threadIds: string[], seen: boolean) => void; + snooze: (threadIds: string[], until: Date) => Promise; + unsnooze: (threadIds: string[]) => Promise; + filing: boolean; +} + +export function useConversationActions(): ConversationActions { + const queryClient = useQueryClient(); + const moveFolder = useMoveFolder(); + const markSeen = useMarkSeen(); + + // One click and the conversations are gone from the list, so the way back + // belongs on screen. The undo calls the endpoint directly rather than the + // mutation: whatever offered it may have unmounted by the time it is + // clicked, and react-query drops an unmounted observer's callbacks. + const offerUndo = React.useCallback( + (message: string, threadIds: string[]) => { + toast((t) => ( + + {message} + + + )); + }, + [queryClient], + ); + + const file = React.useCallback( + async (threadIds: string[], folder: FilableFolder, ids?: string[]) => { + if (threadIds.length === 0) return; + const copy = FILE_COPY[folder]; + const done = + threadIds.length === 1 + ? copy.done + : `${copy.done} ${threadIds.length.toLocaleString()}`; + try { + await moveFolder.mutateAsync({ ids, folder, threadIds }); + if (folder === "inbox") toast.success(done); + else offerUndo(done, threadIds); + } catch { + toast.error(copy.failed); + } + }, + [moveFolder, offerUndo], + ); + + const setSeen = React.useCallback( + (threadIds: string[], seen: boolean) => { + if (threadIds.length === 0) return; + markSeen.mutate({ threadIds, seen }); + if (threadIds.length > 1) { + toast.success( + `${plural(threadIds.length, "Conversation")} marked as ${seen ? "read" : "unread"}`, + ); + } + }, + [markSeen], + ); + + const snooze = React.useCallback( + async (threadIds: string[], until: Date) => { + if (threadIds.length === 0) return; + if ( + Number.isNaN(until.getTime()) || + until.getTime() <= Date.now() + 5_000 + ) { + toast.error("Pick a future time (a few seconds out, please)"); + return; + } + if (until.getTime() - Date.now() > SNOOZE_MAX_MS) { + toast.error("Snooze can't be more than 90 days out"); + return; + } + // Gone from the list the moment it is snoozed; the refetch confirms + // it, and on error that same refetch is the rollback. + await removeThreadsFromLists(queryClient, threadIds); + try { + await snoozeThreads(threadIds, until); + toast.success( + threadIds.length === 1 + ? "Snoozed" + : `Snoozed ${threadIds.length.toLocaleString()}`, + ); + } catch { + toast.error("Couldn't snooze"); + } finally { + queryClient.invalidateQueries({ queryKey: ["unibox"] }); + } + }, + [queryClient], + ); + + const unsnooze = React.useCallback( + async (threadIds: string[]) => { + if (threadIds.length === 0) return; + try { + await unsnoozeThreads(threadIds); + toast.success("Un-snoozed"); + } catch { + toast.error("Couldn't un-snooze"); + } finally { + queryClient.invalidateQueries({ queryKey: ["unibox"] }); + } + }, + [queryClient], + ); + + return { file, setSeen, snooze, unsnooze, filing: moveFolder.isPending }; +} diff --git a/web/src/hooks/useKeyboardShortcuts.ts b/web/src/hooks/useKeyboardShortcuts.ts index 3d8224bbc..af5dd9623 100644 --- a/web/src/hooks/useKeyboardShortcuts.ts +++ b/web/src/hooks/useKeyboardShortcuts.ts @@ -184,6 +184,22 @@ export const globalShortcuts: GlobalShortcut[] = [ match: (e) => e.key === 'Escape' && !layerOpen(), run: () => shortcutAction('listDeselect')?.(), }, + { + keys: ['x'], + description: 'Select / deselect the focused row', + group: 'list', + needs: 'listToggleSelect', + match: plain('x'), + run: () => shortcutAction('listToggleSelect')?.(), + }, + { + keys: ['e'], + description: 'Archive the selection', + group: 'list', + needs: 'listArchive', + match: plain('e'), + run: () => shortcutAction('listArchive')?.(), + }, { keys: ['c'], description: 'Label the open conversation', diff --git a/web/src/hooks/useShortcutActions.ts b/web/src/hooks/useShortcutActions.ts index 0ed7301c4..4b6823b53 100644 --- a/web/src/hooks/useShortcutActions.ts +++ b/web/src/hooks/useShortcutActions.ts @@ -22,6 +22,10 @@ export type ShortcutActions = Partial<{ listOpen: () => void; /** Drop the selection. */ listDeselect: () => void; + /** Tick or untick the focused row, for a list that supports multi-select. */ + listToggleSelect: () => void; + /** Archive the ticked rows, or the focused one when nothing is ticked. */ + listArchive: () => void; /** Put the caret in this screen's own search box. */ focusSearch: () => void; /** Open the label menu for the open conversation. */ diff --git a/web/src/lib/api/client/app/unibox/markSeen.ts b/web/src/lib/api/client/app/unibox/markSeen.ts index 3f468ec15..1cae27c9e 100644 --- a/web/src/lib/api/client/app/unibox/markSeen.ts +++ b/web/src/lib/api/client/app/unibox/markSeen.ts @@ -1,16 +1,26 @@ import Request from "../../Request"; // PATCH /unibox/seen marks unibox emails seen/unseen. The backend body is -// { email_ids, folder, seen } (models.MarkSeen); callers pass { ids } for an -// explicit list or { folder } to sweep a whole folder, and seen defaults to -// true (mark as read). Sending the wrong field names makes the server bind an -// empty list and silently no-op, which is why the unread bar never cleared -// before. -export default async function markSeen(data: { ids?: string[]; folder?: string; seen?: boolean }): Promise { +// { email_ids, thread_ids, folder, seen } (models.MarkSeen); callers pass +// { ids } for an explicit list, { threadIds } for whole conversations, or +// { folder } to sweep a whole folder, and seen defaults to true (mark as +// read). Sending the wrong field names makes the server bind an empty list and +// silently no-op, which is why the unread bar never cleared before. +export default async function markSeen(data: { + ids?: string[]; + threadIds?: string[]; + folder?: string; + seen?: boolean; +}): Promise { return await Request({ method: "PATCH", url: `/unibox/seen`, - data: { email_ids: data.ids ?? [], folder: data.folder, seen: data.seen ?? true }, + data: { + email_ids: data.ids ?? [], + thread_ids: data.threadIds ?? [], + folder: data.folder, + seen: data.seen ?? true, + }, authorization: true, }) } diff --git a/web/src/lib/api/client/app/unibox/moveFolder.ts b/web/src/lib/api/client/app/unibox/moveFolder.ts index f60a2160b..b00a3c126 100644 --- a/web/src/lib/api/client/app/unibox/moveFolder.ts +++ b/web/src/lib/api/client/app/unibox/moveFolder.ts @@ -7,11 +7,23 @@ export type FilableFolder = "inbox" | "archive" | "trash"; // PATCH /unibox/folder re-files messages. Archive in the thread header is // "archive", Delete is "trash", Move to inbox is "inbox". Store-side only: the // provider copy stays put, and the sync knows not to undo it. -export default async function moveFolder(data: { ids: string[]; folder: FilableFolder }): Promise { +// +// Address it by thread wherever the caller has one. A list row knows its +// conversation and not the message ids inside it, and filing part of a +// conversation leaves it in the view it was filed out of. +export default async function moveFolder(data: { + ids?: string[]; + threadIds?: string[]; + folder: FilableFolder; +}): Promise { return await Request({ method: "PATCH", url: `/unibox/folder`, - data: { email_ids: data.ids, folder: data.folder }, + data: { + email_ids: data.ids ?? [], + thread_ids: data.threadIds ?? [], + folder: data.folder, + }, authorization: true, }) } diff --git a/web/src/lib/api/client/app/unibox/searchIncoming.ts b/web/src/lib/api/client/app/unibox/searchIncoming.ts index d733e8f04..741d4aff7 100644 --- a/web/src/lib/api/client/app/unibox/searchIncoming.ts +++ b/web/src/lib/api/client/app/unibox/searchIncoming.ts @@ -56,6 +56,7 @@ export default async function searchIncoming( usp.set("email_ids", p.accountIds.join(",")); } if (p.folder) usp.set("folder", p.folder); + if (p.includeArchived) usp.set("include_archived", "true"); if (p.unseen) usp.set("unseen", "true"); if (p.snoozed === true) usp.set("snoozed", "true"); else if (p.snoozed === "any") usp.set("snoozed", "any"); diff --git a/web/src/lib/api/client/app/unibox/snoozeThread.ts b/web/src/lib/api/client/app/unibox/snoozeThread.ts index aa8af7f42..81f295ee4 100644 --- a/web/src/lib/api/client/app/unibox/snoozeThread.ts +++ b/web/src/lib/api/client/app/unibox/snoozeThread.ts @@ -21,8 +21,26 @@ export async function snoozeThread(req: SnoozeRequest): Promise }); } +// The selection bar's form: one call for a whole selection rather than one per +// row. The server answers with `data` when several are named. +export async function snoozeThreads( + threadIds: string[], + until: Date, +): Promise { + await Request<{ data: SnoozeResponse[] }>({ + method: "POST", + url: "/unibox/snooze", + authorization: true, + data: { thread_ids: threadIds, snoozed_until: until.toISOString() }, + }); +} + export async function unsnoozeThread(threadId: string): Promise { - const usp = new URLSearchParams({ thread_id: threadId }); + return await unsnoozeThreads([threadId]); +} + +export async function unsnoozeThreads(threadIds: string[]): Promise { + const usp = new URLSearchParams({ thread_id: threadIds.join(",") }); await Request({ method: "DELETE", url: `/unibox/snooze?${usp.toString()}`, diff --git a/web/src/lib/api/hooks/app/unibox/useMarkSeen.ts b/web/src/lib/api/hooks/app/unibox/useMarkSeen.ts index ecdaddbdf..391f4e9ff 100644 --- a/web/src/lib/api/hooks/app/unibox/useMarkSeen.ts +++ b/web/src/lib/api/hooks/app/unibox/useMarkSeen.ts @@ -12,22 +12,26 @@ interface MarkSeenInput { ids?: string[]; folder?: string; seen?: boolean; - /** Conversation the ids belong to, so the open list can be patched in place. */ - threadId?: string; + /** + * Conversations to flip. The open list is patched in place, and the server + * flips every message in each, which a list row could not name. + */ + threadIds?: string[]; } export default function useMarkSeen() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (data: MarkSeenInput) => markSeen(data), + mutationFn: ({ ids, folder, seen, threadIds }: MarkSeenInput) => + markSeen({ ids, threadIds, folder, seen }), // Reading a thread must not move the list the user is reading from. // Refetching every loaded page of ["unibox","search"] would re-order // rows around whatever arrived since, so the read/unread flip is // written straight into the cache instead; only the counters, which no // pointer is aimed at, are refetched. - onMutate: async ({ ids, threadId, seen = true, folder }) => { - if (folder || (!threadId && !ids?.length)) return; + onMutate: async ({ ids, threadIds, seen = true, folder }) => { + if (folder || (!threadIds?.length && !ids?.length)) return; // A refetch already in flight would land on top of the patch below // and put the row back to unread. Only refetches: cancelling a // first load would leave that list with no data and nothing queued @@ -37,8 +41,11 @@ export default function useMarkSeen() { predicate: (query) => query.state.data !== undefined, }); const idSet = new Set(ids ?? []); + // Rows with no thread id of their own are keyed by message id, the + // same fallback the list and the server collapse on. + const threadSet = new Set(threadIds ?? []); const matches = (row: { id: string; thread_id?: string }) => - (threadId != null && row.thread_id === threadId) || idSet.has(row.id); + threadSet.has(row.thread_id || row.id) || idSet.has(row.id); queryClient.setQueriesData>( { queryKey: ["unibox", "search"] }, @@ -58,7 +65,7 @@ export default function useMarkSeen() { }, ); - if (threadId) { + for (const threadId of threadIds ?? []) { queryClient.setQueriesData( { queryKey: ["unibox", "thread", threadId] }, (old) => diff --git a/web/src/lib/api/hooks/app/unibox/useMoveFolder.ts b/web/src/lib/api/hooks/app/unibox/useMoveFolder.ts index d4b954bb4..db6648f55 100644 --- a/web/src/lib/api/hooks/app/unibox/useMoveFolder.ts +++ b/web/src/lib/api/hooks/app/unibox/useMoveFolder.ts @@ -3,20 +3,25 @@ import moveFolder, { type FilableFolder } from "@/lib/api/client/app/unibox/move import { removeThreadsFromLists } from "./listCache"; interface MoveFolderInput { - ids: string[]; + /** Explicit message ids, for a caller that has the thread loaded. */ + ids?: string[]; folder: FilableFolder; - /** Conversation the ids belong to, so its row leaves the open list at once. */ - threadId?: string; + /** + * Conversations to file. Their rows leave the open list at once, and the + * server files every message in each, which a list row could not name. + */ + threadIds?: string[]; } export default function useMoveFolder() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ ids, folder }: MoveFolderInput) => moveFolder({ ids, folder }), - // The row goes now; the refetch below confirms it. - onMutate: async ({ threadId }) => { - if (threadId) await removeThreadsFromLists(queryClient, [threadId]); + mutationFn: ({ ids, folder, threadIds }: MoveFolderInput) => + moveFolder({ ids, threadIds, folder }), + // The rows go now; the refetch below confirms it. + onMutate: async ({ threadIds }) => { + if (threadIds?.length) await removeThreadsFromLists(queryClient, threadIds); }, // A move changes which scopes the thread belongs to and every folder's // counts, so the whole unibox tree is re-read rather than patched. On diff --git a/web/src/lib/api/models/app/unibox/UniboxSearch.ts b/web/src/lib/api/models/app/unibox/UniboxSearch.ts index 0caf16d0c..d9087dac5 100644 --- a/web/src/lib/api/models/app/unibox/UniboxSearch.ts +++ b/web/src/lib/api/models/app/unibox/UniboxSearch.ts @@ -53,10 +53,17 @@ export interface UniboxSearchParams { */ snoozed?: true | "any"; /** - * Folder scope. Undefined = every folder except spam and trash (the - * combined view never shows junk). + * Folder scope. Undefined = every working folder: spam, trash and archive + * all stay out, so filing a conversation takes it out of every view the + * workspace reads from and not only the Inbox folder. */ folder?: UniboxFolder; + /** + * Put filed conversations back into an unscoped list. Only "All mail" and + * reference reads (the composer's history panel) ask for it; a working view + * that included archived mail would make Archive look like it did nothing. + */ + includeArchived?: boolean; /** Awaiting reply: threads where the last message was from us. */ awaitingReply?: boolean; /** Agent drafts: threads with a pending inbox-agent reply draft. */ diff --git a/web/src/lib/unibox/snooze.ts b/web/src/lib/unibox/snooze.ts new file mode 100644 index 000000000..81b9b0c99 --- /dev/null +++ b/web/src/lib/unibox/snooze.ts @@ -0,0 +1,41 @@ +// Snooze times, shared by the thread header and the conversation row's menu. +// One definition so the two offer the same presets and the same ceiling. + +/** Server ceiling, matching SnoozeMaxHorizon in internal/app/unibox/config.go. */ +export const SNOOZE_MAX_MS = 90 * 24 * 60 * 60 * 1000; + +export function offsetHours(h: number): Date { + const d = new Date(); + d.setHours(d.getHours() + h); + return d; +} + +export function offsetDays(days: number): Date { + const d = new Date(); + d.setDate(d.getDate() + days); + return d; +} + +export function atHour(dayOffset: number, hour: number): Date { + const d = new Date(); + d.setDate(d.getDate() + dayOffset); + d.setHours(hour, 0, 0, 0); + return d; +} + +export function nextMonday9(): Date { + const d = new Date(); + const dow = d.getDay(); + const delta = (1 - dow + 7) % 7 || 7; + d.setDate(d.getDate() + delta); + d.setHours(9, 0, 0, 0); + return d; +} + +export const SNOOZE_PRESETS: { label: string; until: () => Date }[] = [ + { label: "In 1 hour", until: () => offsetHours(1) }, + { label: "In 3 hours", until: () => offsetHours(3) }, + { label: "Tomorrow 9:00", until: () => atHour(1, 9) }, + { label: "Monday 9:00", until: () => nextMonday9() }, + { label: "Next week", until: () => offsetDays(7) }, +];