mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-12 00:05:09 +00:00
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:
committed by
Matthew Meszaros
parent
73f6bff2ed
commit
727ddb1482
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user