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];
+}