From 727ddb1482bdb91b0d5735cebfb2bea36bc733fb Mon Sep 17 00:00:00 2001 From: SUMAN JANA Date: Fri, 11 Sep 2026 03:10:18 -0700 Subject: [PATCH] feat: wire the unibox thread header's Mark as unread, Archive and Delete to a new PATCH /unibox/folder, add an Add as contact action for senders outside the CRM, and replace six private From-header parsers with one shared lib/helper/emailAddress that also understands the parenthesised form the IMAP sync stores, which left the reply composer's seeded To failing its own validator --- internal/api/handler/unibox.go | 24 ++++++++ internal/api/routes.go | 1 + internal/app/consumer/event_update_email.go | 11 +++- internal/app/unibox/seen.go | 17 ++++++ internal/app/unibox/service.go | 1 + internal/models/unibox.go | 7 +++ internal/repository/pg_unibox.go | 17 +++++- .../app/unibox/ContactContextPanel.tsx | 58 ++++++++++++++++--- .../app/unibox/ConversationItem.tsx | 5 +- .../app/unibox/InsertBookingLink.tsx | 7 +-- .../components/app/unibox/MessageBubble.tsx | 14 +---- .../components/app/unibox/ReplyComposer.tsx | 12 +--- web/src/components/app/unibox/ThreadView.tsx | 55 +++++++++++++++--- .../unibox/compose/ComposeHistoryPanel.tsx | 7 +-- .../app/unibox/compose/ComposeWindow.tsx | 7 +-- .../lib/api/client/app/unibox/moveFolder.ts | 13 +++++ .../lib/api/hooks/app/unibox/useMoveFolder.ts | 15 +++++ web/src/lib/helper/emailAddress.test.ts | 28 +++++++++ web/src/lib/helper/emailAddress.ts | 24 ++++++++ 19 files changed, 258 insertions(+), 65 deletions(-) create mode 100644 web/src/lib/api/client/app/unibox/moveFolder.ts create mode 100644 web/src/lib/api/hooks/app/unibox/useMoveFolder.ts create mode 100644 web/src/lib/helper/emailAddress.test.ts create mode 100644 web/src/lib/helper/emailAddress.ts diff --git a/internal/api/handler/unibox.go b/internal/api/handler/unibox.go index 445b6eb7..a5180c0c 100644 --- a/internal/api/handler/unibox.go +++ b/internal/api/handler/unibox.go @@ -387,6 +387,30 @@ func (h *Handler) UniboxMarkSeen(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// UniboxMoveFolder re-files messages (Delete = trash, Archive = archive). +// PATCH /unibox/folder +func (h *Handler) UniboxMoveFolder(c *gin.Context) { + orgID := middleware.GetOrganizationID(c) + if orgID == nil { + errx.Handle(c, errx.ErrUser) + return + } + + var data models.MoveFolder + if err := c.ShouldBindJSON(&data); err != nil { + errx.Handle(c, errx.ErrInvalid) + return + } + + resp, xerr := h.UniboxService.MoveFolderBulk(c.Request.Context(), *orgID, &data) + if xerr != nil { + errx.Handle(c, xerr) + return + } + + c.JSON(http.StatusOK, resp) +} + // GetUnseenCount gets the count of unseen emails // GET /unibox/count func (h *Handler) GetUnseenCount(c *gin.Context) { diff --git a/internal/api/routes.go b/internal/api/routes.go index e162be93..08f731d8 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -758,6 +758,7 @@ func Run( unibox.PUT("/thread/labels", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.SetUniboxThreadLabels) unibox.PATCH("/seen", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.UniboxMarkSeen) + unibox.PATCH("/folder", m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.UniboxMoveFolder) unibox.POST("/reply", m.RequireOrganization(), m.RequireAccess(models.PermAccessUnibox, models.APIPermWriteUnibox), h.UniboxReply) // Compose: send a brand-new outbound email. The candidates // endpoint scores mailboxes for a recipient (affinity, budget, diff --git a/internal/app/consumer/event_update_email.go b/internal/app/consumer/event_update_email.go index 99255c3f..0e49522f 100644 --- a/internal/app/consumer/event_update_email.go +++ b/internal/app/consumer/event_update_email.go @@ -36,8 +36,13 @@ func (s *JobsService) HandleUpdateEmail(ctx context.Context, e *models.JobEventE updateData.FolderPath = &e.FolderPath } // A folder move follows the provider. Events from workers predating the - // field carry "", which keeps the stored value. - if models.ValidFolder(e.Folder) && email.Folder != e.Folder { + // field carry "", which keeps the stored value. Delete/Archive in the + // thread header only re-file the row here, so a later flag change on the + // provider (still reporting inbox) must not pull the message back out. + localMove := (email.Folder == models.FolderTrash || email.Folder == models.FolderArchive) && + e.Folder == models.FolderInbox + followProvider := models.ValidFolder(e.Folder) && !localMove + if followProvider && email.Folder != e.Folder { updateData.Folder = &e.Folder } @@ -49,7 +54,7 @@ func (s *JobsService) HandleUpdateEmail(ctx context.Context, e *models.JobEventE email.UID = e.UID email.Mailbox = e.Mailbox email.ModSeq = e.ModSeq - if models.ValidFolder(e.Folder) { + if followProvider { email.Folder = e.Folder } s.publishEmailUpdated(ctx, e.UserID, email) diff --git a/internal/app/unibox/seen.go b/internal/app/unibox/seen.go index 0844581f..2194d8c9 100644 --- a/internal/app/unibox/seen.go +++ b/internal/app/unibox/seen.go @@ -46,3 +46,20 @@ func (s *uniboxService) MarkSeenBulk(ctx context.Context, orgID uuid.UUID, data return data, nil } + +// MoveFolderBulk backs Delete (trash) and Archive in the thread header. +// ponytail: store-side only; the provider copy stays where it is. A +// provider-side move needs a worker event per client (IMAP/Gmail/Graph). +func (s *uniboxService) MoveFolderBulk(ctx context.Context, orgID uuid.UUID, data *models.MoveFolder) (*models.MoveFolder, *errx.Error) { + if len(data.EmailIDs) > 500 { + return nil, errx.ErrSeenMax + } + if !models.ValidFolder(data.Folder) { + return nil, errx.ErrUniboxFolder + } + if err := s.uniboxRepository.MoveToFolderBulk(ctx, orgID, data.EmailIDs, data.Folder); err != nil { + errs.CaptureException(err) + return nil, errx.InternalError() + } + return data, nil +} diff --git a/internal/app/unibox/service.go b/internal/app/unibox/service.go index f1ec0345..00ccad5c 100644 --- a/internal/app/unibox/service.go +++ b/internal/app/unibox/service.go @@ -45,6 +45,7 @@ type UniboxService interface { ) (int64, *errx.Error) MarkSeen(ctx context.Context, userID, emailID uuid.UUID, seen bool) *errx.Error 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) diff --git a/internal/models/unibox.go b/internal/models/unibox.go index 1c8f87f1..7f574269 100644 --- a/internal/models/unibox.go +++ b/internal/models/unibox.go @@ -297,6 +297,13 @@ type MarkSeen struct { Seen bool `json:"seen"` } +// MoveFolder re-files messages into one canonical folder (Delete = trash, +// Archive = archive). Store-side only: the provider copy is not moved. +type MoveFolder struct { + EmailIDs []uuid.UUID `json:"email_ids"` + Folder string `json:"folder"` +} + // UniboxSnooze hides a thread from the user's inbox until SnoozedUntil // passes. UNIQUE per (user, thread); a second snooze on the same // thread updates SnoozedUntil in place. diff --git a/internal/repository/pg_unibox.go b/internal/repository/pg_unibox.go index 6935c65f..734f90d4 100644 --- a/internal/repository/pg_unibox.go +++ b/internal/repository/pg_unibox.go @@ -44,6 +44,9 @@ type UniboxRepository interface { // 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) 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 Delete(ctx context.Context, userID, id uuid.UUID) error // Snooze: per (user, thread). UpsertSnooze adopts the new @@ -664,6 +667,18 @@ func (r *uniboxRepository) MarkSeenByFolder(ctx context.Context, orgID uuid.UUID return err } +func (r *uniboxRepository) MoveToFolderBulk(ctx context.Context, orgID uuid.UUID, ids []uuid.UUID, folder string) error { + if len(ids) == 0 { + return nil + } + _, err := r.db.Exec(ctx, + `UPDATE unibox_emails SET folder = $1, updated_at = NOW() + WHERE id = ANY($3) AND email_id IN (SELECT id FROM email_accounts WHERE organization_id = $2)`, + folder, orgID, ids, + ) + return err +} + func (r *uniboxRepository) Delete(ctx context.Context, userID, id uuid.UUID) error { _, err := r.db.Exec(ctx, `DELETE FROM unibox_emails WHERE user_id = $1 AND id = $2`, @@ -877,7 +892,7 @@ func (r *uniboxRepository) LatestThreadIDForContact(ctx context.Context, userID WHERE user_id = $1 AND thread_id <> '' AND EXISTS ( SELECT 1 FROM unnest(from_addr) a - WHERE lower(coalesce(substring(a from '<([^>]*)>'), btrim(a))) = lower($2) + WHERE lower(coalesce(substring(a from '<([^>]*)>'), substring(a from '\(([^()]*)\)\s*$'), btrim(a))) = lower($2) ) ORDER BY internal_date DESC LIMIT 1 diff --git a/web/src/components/app/unibox/ContactContextPanel.tsx b/web/src/components/app/unibox/ContactContextPanel.tsx index fe527bbf..a7e87f08 100644 --- a/web/src/components/app/unibox/ContactContextPanel.tsx +++ b/web/src/components/app/unibox/ContactContextPanel.tsx @@ -44,6 +44,8 @@ import useContact from "@/lib/api/hooks/app/contacts/useContact"; import useContactDeals from "@/lib/api/hooks/app/contacts/useContactDeals"; import useContactNotes from "@/lib/api/hooks/app/contacts/useContactNotes"; import useCreateContactNote from "@/lib/api/hooks/app/contacts/useCreateContactNote"; +import useAddContacts from "@/lib/api/hooks/app/contacts/useAddContacts"; +import { useQueryClient } from "@tanstack/react-query"; import useCRMTasks from "@/lib/api/hooks/app/crm/tasks/useCRMTasks"; import useCreateCRMTask from "@/lib/api/hooks/app/crm/tasks/useCreateCRMTask"; import useCreateDeal from "@/lib/api/hooks/app/crm/deals/useCreateDeal"; @@ -77,10 +79,14 @@ const PRIORITY_OPTS: { id: CRMTask["priority"]; label: string }[] = [ export default function ContactContextPanel({ email, + name: fromName, mailboxId, onClose, }: { email?: string; + // Display name from the message's From header, used when adding the + // sender as a contact. + name?: string; mailboxId?: string; onClose?: () => void; }) { @@ -140,7 +146,7 @@ export default function ContactContextPanel({ Resolving contact… ) : !contact ? ( - + ) : (
{/* Identity */} @@ -788,7 +794,29 @@ function RowSkeleton() { ); } -function NotAContact({ email }: { email?: string }) { +// A reply from someone outside the CRM. One click creates the contact from +// the From header; the by-email lookup is invalidated so this panel flips to +// the full contact view where the rest can be edited. +function NotAContact({ email, name }: { email?: string; name?: string }) { + const add = useAddContacts(); + const queryClient = useQueryClient(); + + async function onAdd() { + if (!email) return; + const parts = (name ?? "").trim().split(/\s+/).filter(Boolean); + const first_name = parts[0] ?? ""; + const last_name = parts.slice(1).join(" "); + try { + await toast.promise( + add.mutateAsync([{ first_name, last_name, email, company: "", phone: "", campaigns: [], custom_fields: {}, source: "manual" }]), + { loading: "Adding contact…", success: "Contact added", error: "Couldn't add contact" }, + ); + await queryClient.invalidateQueries({ queryKey: ["contacts", "by-email", email] }); + } catch { + /* surfaced */ + } + } + return (
@@ -796,13 +824,25 @@ function NotAContact({ email }: { email?: string }) {

Not a known contact

{email &&

{email}

} - - - Manage contacts - +
+ {email && ( + + )} + + Manage contacts + +
); } diff --git a/web/src/components/app/unibox/ConversationItem.tsx b/web/src/components/app/unibox/ConversationItem.tsx index 983e7e36..408af0be 100644 --- a/web/src/components/app/unibox/ConversationItem.tsx +++ b/web/src/components/app/unibox/ConversationItem.tsx @@ -9,6 +9,7 @@ import type UniboxEmail from "@/lib/api/models/app/unibox/UniboxEmail"; import { useAppStore } from "@/stores"; import { useResourceViewers } from "@/hooks/PresenceProvider"; import { cn } from "@/lib/utils"; +import { nameFromAddr } from "@/lib/helper/emailAddress"; function relative(d: Date): string { const diff = Date.now() - d.getTime(); @@ -24,9 +25,7 @@ function relative(d: Date): string { function fromName(s: string): string { if (!s) return "Unknown sender"; - const m = s.match(/^"?([^"<]+)"?\s*<.+>$/); - if (m) return m[1].trim(); - return s.replace(/<.+>/, "").trim() || s; + return nameFromAddr(s); } function initials(s: string): string { diff --git a/web/src/components/app/unibox/InsertBookingLink.tsx b/web/src/components/app/unibox/InsertBookingLink.tsx index 7082fc5a..ce3c8730 100644 --- a/web/src/components/app/unibox/InsertBookingLink.tsx +++ b/web/src/components/app/unibox/InsertBookingLink.tsx @@ -7,12 +7,7 @@ import { CalendarPlusIcon } from "lucide-react"; import toast from "react-hot-toast"; import useIntegrationConnections from "@/lib/api/hooks/app/integrations/useIntegrationConnections"; import { bookingURL, prefilledBookingURL } from "@/lib/api/models/app/integrations/Integration"; - -function bareEmail(s: string): string { - const m = s.match(/<([^>]+)>/); - if (m) return m[1].trim(); - return s.trim(); -} +import { bareEmail } from "@/lib/helper/emailAddress"; export default function InsertBookingLink({ email, diff --git a/web/src/components/app/unibox/MessageBubble.tsx b/web/src/components/app/unibox/MessageBubble.tsx index ad7244e3..9a3f3149 100644 --- a/web/src/components/app/unibox/MessageBubble.tsx +++ b/web/src/components/app/unibox/MessageBubble.tsx @@ -18,6 +18,7 @@ import { AlertCircleIcon, CornerUpLeftIcon, ForwardIcon, Loader2Icon } from "luc import EmailBody from "./EmailBody"; import useUniboxEmail from "@/lib/api/hooks/app/unibox/useUniboxEmail"; import type UniboxEmail from "@/lib/api/models/app/unibox/UniboxEmail"; +import { nameFromAddr, wrappedEmail } from "@/lib/helper/emailAddress"; interface MessageBubbleProps { email: UniboxEmail; @@ -27,17 +28,8 @@ interface MessageBubbleProps { onForward?: () => void; } -function fromName(s: string): string { - const m = s.match(/^"?([^"<]+)"?\s*<.+>$/); - if (m) return m[1].trim(); - return s.replace(/<.+>/, "").trim() || s; -} - -function fromAddr(s: string): string | null { - const m = s.match(/<([^>]+)>/); - if (m) return m[1].trim(); - return null; -} +const fromName = nameFromAddr; +const fromAddr = wrappedEmail; function initials(s: string): string { const name = fromName(s); diff --git a/web/src/components/app/unibox/ReplyComposer.tsx b/web/src/components/app/unibox/ReplyComposer.tsx index 8270eb19..46b7d4e1 100644 --- a/web/src/components/app/unibox/ReplyComposer.tsx +++ b/web/src/components/app/unibox/ReplyComposer.tsx @@ -53,6 +53,7 @@ import { } from "@/components/ui/popover-menu"; import { cn } from "@/lib/utils"; import { plainToHtml } from "@/lib/email/body"; +import { bareEmail, nameFromAddr } from "@/lib/helper/emailAddress"; export type ReplyMode = "reply" | "forward"; @@ -138,17 +139,6 @@ function looksLikeEmail(s: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim()); } -function nameFromAddr(s: string): string { - const m = s.match(/^"?([^"<]+)"?\s*<.+>$/); - if (m) return m[1].trim(); - return s.replace(/<.+>/, "").trim() || s; -} - -function bareEmail(s: string): string { - const m = s.match(/<([^>]+)>/); - if (m) return m[1].trim(); - return s.trim(); -} // Derive composer defaults from the message the user explicitly chose // to reply to (or forward). Reply takes the message's "from" as the diff --git a/web/src/components/app/unibox/ThreadView.tsx b/web/src/components/app/unibox/ThreadView.tsx index 641928b3..d69dbb6d 100644 --- a/web/src/components/app/unibox/ThreadView.tsx +++ b/web/src/components/app/unibox/ThreadView.tsx @@ -41,6 +41,8 @@ import { CategoryChip } from "@/components/app/contacts/CategoryPicker"; import { SectionBar } from "@/components/layout/Page"; 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 { 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"; import cancelScheduled from "@/lib/api/client/app/unibox/cancelScheduled"; @@ -263,6 +265,27 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) { markSeenMutate({ ids: unseenIds, 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 + // trashed/archived thread has left the list the reader is looking at. + const moveFolder = useMoveFolder(); + const setSelectedThreadId = useAppStore((s) => s.setSelectedThreadId); + const threadIds = () => (q.data?.data ?? []).map((m) => m.id); + const markUnread = () => { + markSeenMutate({ ids: threadIds(), seen: false }); + setSelectedThreadId(null); + }; + const fileThread = (folder: "trash" | "archive") => { + toast + .promise(moveFolder.mutateAsync({ ids: threadIds(), folder }), { + loading: folder === "trash" ? "Deleting…" : "Archiving…", + success: folder === "trash" ? "Moved to Trash" : "Archived", + error: folder === "trash" ? "Couldn't delete" : "Couldn't archive", + }) + .then(() => setSelectedThreadId(null)) + .catch(() => undefined); + }; + const snooze = useMutation({ mutationFn: (until: Date) => snoozeThread({ thread_id: threadId, snoozed_until: until.toISOString() }), @@ -340,15 +363,20 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) { // our own mailbox. Addresses arrive as "Name " or bare "addr"; reduce // to the bare address so the comparison + the CRM panel lookup both work. const mailboxEmail = mailbox?.email?.toLowerCase(); - const bareAddr = (s: string) => { - const m = s.match(/<([^>]+)>/); - return (m ? m[1] : s).trim(); - }; - const contactEmail = + const contactFrom = messages - .map((m) => bareAddr(m.from)) - .find((e) => e && e.toLowerCase() !== mailboxEmail) ?? - bareAddr(messages[0]?.from ?? ""); + .map((m) => m.from) + .find((f) => { + const e = bareEmail(f); + return e && e.toLowerCase() !== mailboxEmail; + }) ?? (messages[0]?.from ?? ""); + const contactEmail = bareEmail(contactFrom); + // Display name from the From header, so an "Add as contact" from the + // panel does not create a nameless row. Empty when the header is bare. + const contactName = + wrappedEmail(contactFrom) && nameFromAddr(contactFrom) !== contactEmail + ? nameFromAddr(contactFrom) + : ""; const submitCustomSnooze = () => { if (!customValue) return; @@ -505,15 +533,18 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) { } + onClick={markUnread} /> } + onClick={() => fileThread("archive")} /> } + onClick={() => fileThread("trash")} />
@@ -529,15 +560,20 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) { } + onSelect={markUnread} > Mark as unread - }> + } + onSelect={() => fileThread("archive")} + > Archive thread } + onSelect={() => fileThread("trash")} > Delete thread @@ -640,6 +676,7 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) { {crmOpen && ( setCrmOpen(false)} /> diff --git a/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx b/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx index 513dbf94..a98e7f3a 100644 --- a/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx +++ b/web/src/components/app/unibox/compose/ComposeHistoryPanel.tsx @@ -17,6 +17,7 @@ import type { UniboxListRow } from "@/lib/api/client/app/unibox/searchIncoming"; import { SearchInput } from "@/components/ui/field"; import { useAppStore } from "@/stores"; import { cn } from "@/lib/utils"; +import { bareEmail } from "@/lib/helper/emailAddress"; type HistoryTab = "all" | "sent"; @@ -29,12 +30,6 @@ interface ComposeHistoryPanelProps { affinityLine?: string; } -function bareEmail(s: string): string { - const m = s.match(/<([^>]+)>/); - if (m) return m[1].trim(); - return s.trim(); -} - function formatWhen(iso: string): string { const d = new Date(iso); const now = new Date(); diff --git a/web/src/components/app/unibox/compose/ComposeWindow.tsx b/web/src/components/app/unibox/compose/ComposeWindow.tsx index 48de188b..06bcda2f 100644 --- a/web/src/components/app/unibox/compose/ComposeWindow.tsx +++ b/web/src/components/app/unibox/compose/ComposeWindow.tsx @@ -66,6 +66,7 @@ import type { AppError } from "@/lib/api/client/normalizeError"; import buildError from "@/lib/helper/buildError"; import { cn } from "@/lib/utils"; import { plainToHtml } from "@/lib/email/body"; +import { bareEmail } from "@/lib/helper/emailAddress"; const MAX_BODY_LEN = 4000; const MAX_SCHEDULE_MS = 29 * 24 * 60 * 60 * 1000; @@ -74,12 +75,6 @@ function looksLikeEmail(s: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim()); } -function bareEmail(s: string): string { - const m = s.match(/<([^>]+)>/); - if (m) return m[1].trim(); - return s.trim(); -} - function offsetHours(h: number): Date { const d = new Date(); d.setHours(d.getHours() + h); diff --git a/web/src/lib/api/client/app/unibox/moveFolder.ts b/web/src/lib/api/client/app/unibox/moveFolder.ts new file mode 100644 index 00000000..9f31940d --- /dev/null +++ b/web/src/lib/api/client/app/unibox/moveFolder.ts @@ -0,0 +1,13 @@ +import Request from "../../Request"; + +// PATCH /unibox/folder re-files messages into one canonical folder. Delete in +// the thread header is folder "trash", Archive is "archive". Store-side only: +// the provider copy stays put. +export default async function moveFolder(data: { ids: string[]; folder: "trash" | "archive" | "inbox" }): Promise { + return await Request({ + method: "PATCH", + url: `/unibox/folder`, + data: { email_ids: data.ids, folder: data.folder }, + authorization: true, + }) +} diff --git a/web/src/lib/api/hooks/app/unibox/useMoveFolder.ts b/web/src/lib/api/hooks/app/unibox/useMoveFolder.ts new file mode 100644 index 00000000..ddb22de3 --- /dev/null +++ b/web/src/lib/api/hooks/app/unibox/useMoveFolder.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import moveFolder from "@/lib/api/client/app/unibox/moveFolder"; + +export default function useMoveFolder() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: { ids: string[]; folder: "trash" | "archive" | "inbox" }) => moveFolder(data), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["unibox"], + }) + } + }) +} diff --git a/web/src/lib/helper/emailAddress.test.ts b/web/src/lib/helper/emailAddress.test.ts new file mode 100644 index 00000000..752effe6 --- /dev/null +++ b/web/src/lib/helper/emailAddress.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { bareEmail, nameFromAddr, wrappedEmail } from "./emailAddress"; + +// The three shapes the API actually stores (see the header comment), pinned +// so the reply composer's seeded To passes its own validator for each. +describe("emailAddress", () => { + it("parses the IMAP sync's parenthesised form", () => { + const s = "Centous Support (support@centous.com)"; + expect(bareEmail(s)).toBe("support@centous.com"); + expect(nameFromAddr(s)).toBe("Centous Support"); + }); + + it("parses the RFC angle-bracket form, quoted or not", () => { + expect(bareEmail('"Jane Doe" ')).toBe("jane@x.com"); + expect(nameFromAddr('"Jane Doe" ')).toBe("Jane Doe"); + expect(nameFromAddr("Jane Doe ")).toBe("Jane Doe"); + }); + + it("passes a bare address through and names it by itself", () => { + expect(wrappedEmail("jane@x.com")).toBeNull(); + expect(bareEmail(" jane@x.com ")).toBe("jane@x.com"); + expect(nameFromAddr("jane@x.com")).toBe("jane@x.com"); + }); + + it("falls back to the address when the name is empty", () => { + expect(nameFromAddr(" (noreply-dmarc-support@google.com)")).toBe("noreply-dmarc-support@google.com"); + }); +}); diff --git a/web/src/lib/helper/emailAddress.ts b/web/src/lib/helper/emailAddress.ts new file mode 100644 index 00000000..dca82ec9 --- /dev/null +++ b/web/src/lib/helper/emailAddress.ts @@ -0,0 +1,24 @@ +// One parser for the header-style addresses the API hands the UI. They come in +// three shapes: RFC "Name " (Gmail/Graph sync), "Name (addr)" (the IMAP +// sync, internal/client/smtpimap/imap/address.go), or a bare "addr". Every +// component used to carry its own angle-bracket-only copy, so an IMAP sender +// seeded the reply composer with "Name (addr)" and Send stayed disabled. +const WRAPPED = /[<(]\s*([^<>()\s]+@[^<>()\s]+)\s*[>)]\s*$/; + +// The address inside the brackets, or null when there are none. +export function wrappedEmail(s: string): string | null { + const m = s.match(WRAPPED); + return m ? m[1] : null; +} + +// The bare address: the bracketed one when present, else the trimmed input. +export function bareEmail(s: string): string { + return wrappedEmail(s) ?? s.trim(); +} + +// The display name in front of the brackets; the address when there is none. +export function nameFromAddr(s: string): string { + const m = s.match(WRAPPED); + if (!m) return s.trim(); + return s.slice(0, m.index).replace(/"/g, "").trim() || m[1]; +}