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

This commit is contained in:
SUMAN JANA
2026-09-11 03:10:18 -07:00
committed by Matthew Meszaros
parent 73f6bff2ed
commit 727ddb1482
19 changed files with 258 additions and 65 deletions
+24
View File
@@ -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) {
+1
View File
@@ -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,
+8 -3
View File
@@ -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)
+17
View File
@@ -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
}
+1
View File
@@ -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)
+7
View File
@@ -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.
+16 -1
View File
@@ -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
@@ -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
</div>
) : !contact ? (
<NotAContact email={email} />
<NotAContact email={email} name={fromName} />
) : (
<div className="divide-y divide-slate-200/70">
{/* 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 (
<div className="px-3 py-8 text-center">
<div className="mx-auto size-9 rounded-md bg-white border border-slate-200 flex items-center justify-center mb-2.5">
@@ -796,13 +824,25 @@ function NotAContact({ email }: { email?: string }) {
</div>
<p className="text-[12px] font-medium text-slate-700 mb-0.5">Not a known contact</p>
{email && <p className="text-[11px] text-slate-400 break-all mb-3">{email}</p>}
<Link
to="/app/contacts"
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-[11.5px] text-slate-700 hover:text-slate-900 transition-colors"
>
<PlusIcon className="w-3 h-3" />
Manage contacts
</Link>
<div className="flex items-center justify-center gap-1.5">
{email && (
<button
type="button"
onClick={onAdd}
disabled={add.isPending}
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md bg-sky-600 hover:bg-sky-700 text-white text-[11.5px] font-medium transition-colors disabled:opacity-60"
>
{add.isPending ? <Loader2Icon className="w-3 h-3 animate-spin" /> : <PlusIcon className="w-3 h-3" />}
Add as contact
</button>
)}
<Link
to="/app/contacts"
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 text-[11.5px] text-slate-700 hover:text-slate-900 transition-colors"
>
Manage contacts
</Link>
</div>
</div>
);
}
@@ -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 {
@@ -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,
@@ -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);
@@ -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
+46 -9
View File
@@ -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 <addr>" 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) {
<IconAction
label="Mark as unread"
icon={<MailCheckIcon className="w-3.5 h-3.5" />}
onClick={markUnread}
/>
<IconAction
label="Archive thread"
icon={<ArchiveIcon className="w-3.5 h-3.5" />}
onClick={() => fileThread("archive")}
/>
<IconAction
label="Delete thread"
danger
icon={<TrashIcon className="w-3.5 h-3.5" />}
onClick={() => fileThread("trash")}
/>
</div>
<PopoverMenu align="end" side="bottom">
@@ -529,15 +560,20 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
<PopoverMenuContent>
<PopoverMenuItem
icon={<MailCheckIcon className="w-3.5 h-3.5" />}
onSelect={markUnread}
>
Mark as unread
</PopoverMenuItem>
<PopoverMenuItem icon={<ArchiveIcon className="w-3.5 h-3.5" />}>
<PopoverMenuItem
icon={<ArchiveIcon className="w-3.5 h-3.5" />}
onSelect={() => fileThread("archive")}
>
Archive thread
</PopoverMenuItem>
<PopoverMenuItem
danger
icon={<TrashIcon className="w-3.5 h-3.5" />}
onSelect={() => fileThread("trash")}
>
Delete thread
</PopoverMenuItem>
@@ -640,6 +676,7 @@ export function ThreadView({ threadId, emailId }: ThreadViewProps) {
{crmOpen && (
<ContactContextPanel
email={contactEmail}
name={contactName}
mailboxId={mailbox?.id}
onClose={() => setCrmOpen(false)}
/>
@@ -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();
@@ -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);
@@ -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<void> {
return await Request<void>({
method: "PATCH",
url: `/unibox/folder`,
data: { email_ids: data.ids, folder: data.folder },
authorization: true,
})
}
@@ -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"],
})
}
})
}
+28
View File
@@ -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" <jane@x.com>')).toBe("jane@x.com");
expect(nameFromAddr('"Jane Doe" <jane@x.com>')).toBe("Jane Doe");
expect(nameFromAddr("Jane Doe <jane@x.com>")).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");
});
});
+24
View File
@@ -0,0 +1,24 @@
// One parser for the header-style addresses the API hands the UI. They come in
// three shapes: RFC "Name <addr>" (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];
}