human hand off bug

This commit is contained in:
debu-2 2026-10-06 18:27:06 +05:30
parent 223f846782
commit 6e7a0b9539
22 changed files with 1040 additions and 336 deletions

View file

@ -20,6 +20,7 @@ import {
listMyDomains,
verifyMyDomainDns,
} from "../api";
import { formatRelativeTime, formatShortDate } from "../utils/datetime";
const ADD_STEPS = [
{ id: 1, title: "Add domain" },
@ -57,18 +58,6 @@ const DNS_GROUPS = [
},
];
function formatRelativeTime(date) {
if (!date) return null;
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
const days = Math.floor(hours / 24);
return `${days} day${days === 1 ? "" : "s"} ago`;
}
async function copyText(value) {
try {
await navigator.clipboard.writeText(value);
@ -419,7 +408,7 @@ function DomainDetail({
<dl className="company-domain-meta">
<div><dt>Status</dt><dd>{domain.status === "active" ? "Active" : "Pending DNS"}</dd></div>
<div><dt>DKIM selector</dt><dd><code>{domain.dkim_selector}</code></dd></div>
<div><dt>Added</dt><dd>{new Date(domain.created_at).toLocaleDateString()}</dd></div>
<div><dt>Added</dt><dd>{formatShortDate(domain.created_at)}</dd></div>
</dl>
</div>
)}

View file

@ -15,6 +15,7 @@ import {
listMailGroups,
listOrgMembers,
} from "../api";
import { formatRelativeTime } from "../utils/datetime";
const TYPE_TABS = [
{ key: "all", label: "All groups" },
@ -49,17 +50,6 @@ const SEND_PERMISSIONS = [
const EMAIL_LOCAL_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
function formatRelativeTime(dateValue) {
const date = new Date(dateValue);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 3600) return `${Math.max(1, Math.floor(seconds / 60))}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
const days = Math.floor(seconds / 86400);
if (days < 7) return `${days}d ago`;
const weeks = Math.floor(days / 7);
return `${weeks}w ago`;
}
function groupInitial(name) {
return (name?.trim()?.[0] || "G").toUpperCase();
}
@ -594,7 +584,7 @@ export default function CompanyGroups({ account }) {
{group.member_count}
</span>
</td>
<td>{formatRelativeTime(group.created_at)}</td>
<td>{formatRelativeTime(group.created_at, { compact: true })}</td>
<td>
<button type="button" className="people-row-menu" aria-label="More actions">
<MoreHorizontal size={16} />

View file

@ -20,6 +20,7 @@ import {
verifyMyDomainDns,
} from "../api";
import { getOrgDetails } from "./CompanyOverviewSetup";
import { formatRelativeTime, parseApiDate } from "../utils/datetime";
const HEALTH_ITEMS = [
{ purpose: "ownership", label: "Domain verified" },
@ -29,16 +30,6 @@ const HEALTH_ITEMS = [
{ purpose: "dmarc", label: "DMARC" },
];
function formatRelativeTime(dateValue) {
const date = new Date(dateValue);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 3600) return `${Math.max(1, Math.floor(seconds / 60))}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
const days = Math.floor(seconds / 86400);
if (days < 7) return `${days}d ago`;
return `${Math.floor(days / 7)}w ago`;
}
function companyTitle(account, details, domainName) {
if (details?.companyName?.trim()) return details.companyName.trim();
if (domainName) {
@ -147,7 +138,7 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
})),
];
return events
.sort((left, right) => new Date(right.time) - new Date(left.time))
.sort((left, right) => (parseApiDate(right.time)?.getTime() || 0) - (parseApiDate(left.time)?.getTime() || 0))
.slice(0, 5);
}, [groups.items, members.items]);
@ -243,7 +234,7 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
{dnsError
? dnsError
: lastChecked
? `Last checked ${formatRelativeTime(lastChecked)}`
? `Last checked ${formatRelativeTime(lastChecked, { compact: true })}`
: "Not checked yet"}
</span>
<button type="button" onClick={loadDnsHealth} disabled={dnsLoading || !primaryDomain}>
@ -274,7 +265,7 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
</span>
<div>
<p>{item.text}</p>
<time>{formatRelativeTime(item.time)}</time>
<time>{formatRelativeTime(item.time, { compact: true })}</time>
</div>
</li>
);

View file

@ -28,13 +28,14 @@ import {
RemoveFormatting,
IndentIncrease,
IndentDecrease,
Type,
} from "lucide-react";
import AgentSpark from "./AgentSpark";
import RecipientField, { recipientsToPayload } from "./compose/RecipientField";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { listMailAliases, listOrgMembers, sendEmail } from "../api";
import { clearDraft, draftStorageKey } from "../utils/composeDraft";
import { plainTextFromEmailHtml } from "../utils/emailReply";
import { escapeHtml, htmlForOutboundEmail, plainTextFromEmailHtml, sanitizeEmailHtml } from "../utils/emailReply";
const EMOJI_SAMPLES = ["😀", "😊", "👍", "🙏", "✅", "🎉", "📎", "📅", "💡", "🔥", "❤️", "😅"];
@ -115,6 +116,37 @@ function MenuDropdown({
);
}
function normalizeLinkUrl(value) {
const trimmed = (value || "").trim();
if (!trimmed) return "";
if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed;
if (trimmed.startsWith("/") || trimmed.startsWith("#")) return trimmed;
return `https://${trimmed}`;
}
function selectionInside(editor) {
const selection = window.getSelection();
if (!editor || !selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
if (!editor.contains(range.commonAncestorContainer)) return null;
return { selection, range };
}
function FormatButton({ title, active, onClick, children }) {
return (
<button
type="button"
title={title}
className={active ? "active" : ""}
aria-pressed={Boolean(active)}
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
>
{children}
</button>
);
}
function SendSplitButton({ onSend, onSchedule, disabled, iconStroke = 1.7 }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
@ -179,6 +211,8 @@ export default function ComposeModal({
const imageInputRef = useRef(null);
const draftTimerRef = useRef(null);
const draftHydratedRef = useRef(false);
const savedRangeRef = useRef(null);
const linkPopoverRef = useRef(null);
const [showCc, setShowCc] = useState(false);
const [showBcc, setShowBcc] = useState(false);
@ -195,6 +229,8 @@ export default function ComposeModal({
const [notice, setNotice] = useState("");
const [warnings, setWarnings] = useState([]);
const [showEmoji, setShowEmoji] = useState(false);
const [linkDialog, setLinkDialog] = useState(null);
const [formatState, setFormatState] = useState({});
const [showQuoted, setShowQuoted] = useState(false);
const [status, setStatus] = useState({ sending: false, error: "" });
const [replyToMessageId, setReplyToMessageId] = useState(null);
@ -303,6 +339,26 @@ export default function ComposeModal({
setWarnings(detectWarnings({ bodyHtml, to, cc, bcc, attachments, orgDomain }));
}, [bodyHtml, to, cc, bcc, attachments, orgDomain]);
useEffect(() => {
function onSelectionChange() {
const current = selectionInside(editorRef.current);
if (!current) return;
savedRangeRef.current = current.range.cloneRange();
updateFormatState();
}
document.addEventListener("selectionchange", onSelectionChange);
return () => document.removeEventListener("selectionchange", onSelectionChange);
}, []);
useEffect(() => {
if (!linkDialog) return undefined;
function onPointerDown(event) {
if (!linkPopoverRef.current?.contains(event.target)) setLinkDialog(null);
}
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [linkDialog]);
function flashNotice(message) {
setNotice(message);
window.setTimeout(() => setNotice(""), 2800);
@ -310,24 +366,95 @@ export default function ComposeModal({
function syncBodyFromEditor() {
const html = editorRef.current?.innerHTML || "";
setBodyHtml(html === "<br>" ? "" : html);
setBodyHtml(html === "<br>" || html === "<div><br></div>" ? "" : html);
}
function saveSelection() {
const current = selectionInside(editorRef.current);
savedRangeRef.current = current ? current.range.cloneRange() : savedRangeRef.current;
return savedRangeRef.current;
}
function restoreSelection() {
const editor = editorRef.current;
const range = savedRangeRef.current;
if (!editor) return false;
editor.focus();
if (!range) return false;
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
return true;
}
function updateFormatState() {
try {
setFormatState({
bold: document.queryCommandState("bold"),
italic: document.queryCommandState("italic"),
underline: document.queryCommandState("underline"),
strikeThrough: document.queryCommandState("strikeThrough"),
justifyLeft: document.queryCommandState("justifyLeft"),
justifyCenter: document.queryCommandState("justifyCenter"),
justifyRight: document.queryCommandState("justifyRight"),
insertUnorderedList: document.queryCommandState("insertUnorderedList"),
insertOrderedList: document.queryCommandState("insertOrderedList"),
});
} catch {
/* queryCommandState can throw in some browsers */
}
}
function execFormat(command, value = null) {
editorRef.current?.focus();
restoreSelection();
document.execCommand("defaultParagraphSeparator", false, "div");
document.execCommand(command, false, value);
saveSelection();
syncBodyFromEditor();
updateFormatState();
}
function insertLink() {
const url = window.prompt("Enter link URL", "https://");
function openLinkDialog() {
restoreSelection();
saveSelection();
const current = selectionInside(editorRef.current);
const selectedText = current?.selection.toString() || "";
let url = "";
const node = current?.range.startContainer;
const element = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
const anchor = element?.closest?.("a");
if (anchor) url = anchor.getAttribute("href") || "";
setShowEmoji(false);
setLinkDialog({ text: selectedText, url });
}
function applyLink(event) {
event?.preventDefault();
if (!linkDialog) return;
const url = normalizeLinkUrl(linkDialog.url);
if (!url) return;
execFormat("createLink", url);
const label = (linkDialog.text || "").trim() || url;
restoreSelection();
const current = selectionInside(editorRef.current);
const selectedText = current?.selection.toString() || "";
if (selectedText && selectedText === label) {
document.execCommand("createLink", false, url);
} else {
document.execCommand(
"insertHTML",
false,
`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`,
);
}
saveSelection();
syncBodyFromEditor();
setLinkDialog(null);
}
function insertEmoji(emoji) {
editorRef.current?.focus();
restoreSelection();
document.execCommand("insertText", false, emoji);
saveSelection();
syncBodyFromEditor();
setShowEmoji(false);
}
@ -336,9 +463,10 @@ export default function ComposeModal({
if (!signature.html) return;
const editor = editorRef.current;
if (!editor) return;
editor.focus();
restoreSelection();
const block = `${editor.innerHTML ? "<br>" : ""}${signature.html}`;
document.execCommand("insertHTML", false, block);
saveSelection();
syncBodyFromEditor();
flashNotice(`Inserted signature: ${signature.label}`);
}
@ -386,11 +514,25 @@ export default function ComposeModal({
onDiscard?.();
}
function handleEditorPaste(event) {
const html = event.clipboardData?.getData("text/html");
const text = event.clipboardData?.getData("text/plain");
if (!html && !text) return;
event.preventDefault();
if (html) document.execCommand("insertHTML", false, sanitizeEmailHtml(html));
else document.execCommand("insertText", false, text);
syncBodyFromEditor();
}
const submitSend = useCallback(async () => {
if (!to.length) {
setStatus({ sending: false, error: "Add at least one recipient." });
return;
}
const rawHtml = editorRef.current?.innerHTML || bodyHtml;
const outboundHtml = htmlForOutboundEmail(rawHtml);
const outboundText = plainTextFromHtml(outboundHtml || rawHtml);
setBodyHtml(rawHtml === "<br>" || rawHtml === "<div><br></div>" ? "" : rawHtml);
setStatus({ sending: true, error: "" });
try {
const payload = {
@ -398,8 +540,8 @@ export default function ComposeModal({
cc: recipientsToPayload(cc),
bcc: recipientsToPayload(bcc),
subject,
body_text: plainTextFromHtml(bodyHtml),
body_html: bodyHtml || null,
body_text: outboundText,
body_html: outboundHtml || null,
};
if (replyToMessageId) {
payload.reply_to_message_id = replyToMessageId;
@ -419,12 +561,22 @@ export default function ComposeModal({
const mod = event.metaKey || event.ctrlKey;
if (!isFocused) return;
if (mod && event.key === "Enter" && !status.sending) {
if (linkDialog) {
event.preventDefault();
applyLink(event);
return;
}
event.preventDefault();
submitSend();
}
if (event.key === "Escape" && linkDialog) {
event.preventDefault();
setLinkDialog(null);
return;
}
if (mod && event.key === "k") {
event.preventDefault();
insertLink();
openLinkDialog();
}
if (mod && event.shiftKey && event.key.toLowerCase() === "c") {
event.preventDefault();
@ -437,7 +589,7 @@ export default function ComposeModal({
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [status.sending, submitSend, isFocused]);
}, [status.sending, submitSend, isFocused, linkDialog]);
function popOut() {
const url = `${window.location.origin}${window.location.pathname}?popup=compose&compose=${windowId}`;
@ -595,7 +747,14 @@ export default function ComposeModal({
role="textbox"
aria-multiline="true"
data-placeholder="Write your message…"
onInput={syncBodyFromEditor}
onFocus={() => document.execCommand("defaultParagraphSeparator", false, "div")}
onMouseUp={saveSelection}
onKeyUp={saveSelection}
onInput={() => {
syncBodyFromEditor();
updateFormatState();
}}
onPaste={handleEditorPaste}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
@ -614,6 +773,37 @@ export default function ComposeModal({
</button>
)}
{linkDialog && (
<form
className="compose-link-popover"
ref={linkPopoverRef}
role="dialog"
aria-label="Insert link"
onSubmit={applyLink}
>
<label className="compose-link-row">
<Type size={16} strokeWidth={1.7} />
<input
value={linkDialog.text}
onChange={(event) => setLinkDialog((current) => ({ ...current, text: event.target.value }))}
placeholder="Text"
autoFocus
/>
</label>
<label className="compose-link-row">
<Link size={16} strokeWidth={1.7} />
<input
value={linkDialog.url}
onChange={(event) => setLinkDialog((current) => ({ ...current, url: event.target.value }))}
placeholder="Type or paste a link"
/>
<button type="submit" className="compose-link-apply" disabled={!linkDialog.url.trim()}>
Apply
</button>
</label>
</form>
)}
<div className="compose-bottom-stack">
<div className="compose-agent-bar compose-agent-bar-disabled" title="AG Agent drafting — coming soon">
<AgentSpark />
@ -629,29 +819,29 @@ export default function ComposeModal({
<div className="compose-format-bar" aria-label="Formatting">
<div className="compose-format-group">
<button type="button" title="Bold" onClick={() => execFormat("bold")}><Bold size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Italic" onClick={() => execFormat("italic")}><Italic size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Underline" onClick={() => execFormat("underline")}><Underline size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Strikethrough" onClick={() => execFormat("strikeThrough")}><Strikethrough size={14} strokeWidth={iconStroke} /></button>
<FormatButton title="Bold" active={formatState.bold} onClick={() => execFormat("bold")}><Bold size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Italic" active={formatState.italic} onClick={() => execFormat("italic")}><Italic size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Underline" active={formatState.underline} onClick={() => execFormat("underline")}><Underline size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Strikethrough" active={formatState.strikeThrough} onClick={() => execFormat("strikeThrough")}><Strikethrough size={14} strokeWidth={iconStroke} /></FormatButton>
</div>
<span className="compose-format-divider" />
<div className="compose-format-group compose-format-align">
<button type="button" title="Align left" onClick={() => execFormat("justifyLeft")}><AlignLeft size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Align center" onClick={() => execFormat("justifyCenter")}><AlignCenter size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Align right" onClick={() => execFormat("justifyRight")}><AlignRight size={14} strokeWidth={iconStroke} /></button>
<FormatButton title="Align left" active={formatState.justifyLeft} onClick={() => execFormat("justifyLeft")}><AlignLeft size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Align center" active={formatState.justifyCenter} onClick={() => execFormat("justifyCenter")}><AlignCenter size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Align right" active={formatState.justifyRight} onClick={() => execFormat("justifyRight")}><AlignRight size={14} strokeWidth={iconStroke} /></FormatButton>
</div>
<span className="compose-format-divider" />
<div className="compose-format-group">
<button type="button" title="Bulleted list" onClick={() => execFormat("insertUnorderedList")}><List size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Numbered list" onClick={() => execFormat("insertOrderedList")}><ListOrdered size={14} strokeWidth={iconStroke} /></button>
<FormatButton title="Bulleted list" active={formatState.insertUnorderedList} onClick={() => execFormat("insertUnorderedList")}><List size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Numbered list" active={formatState.insertOrderedList} onClick={() => execFormat("insertOrderedList")}><ListOrdered size={14} strokeWidth={iconStroke} /></FormatButton>
</div>
<span className="compose-format-divider compose-format-divider-advanced" />
<div className="compose-format-group compose-format-advanced">
<button type="button" title="Indent" onClick={() => execFormat("indent")}><IndentIncrease size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Outdent" onClick={() => execFormat("outdent")}><IndentDecrease size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Quote" onClick={() => execFormat("formatBlock", "blockquote")}><Quote size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Link" onClick={insertLink}><Link size={14} strokeWidth={iconStroke} /></button>
<button type="button" title="Remove formatting" onClick={() => execFormat("removeFormat")}><RemoveFormatting size={14} strokeWidth={iconStroke} /></button>
<FormatButton title="Indent" onClick={() => execFormat("indent")}><IndentIncrease size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Outdent" onClick={() => execFormat("outdent")}><IndentDecrease size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Quote" onClick={() => execFormat("formatBlock", "blockquote")}><Quote size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Link" onClick={openLinkDialog}><Link size={14} strokeWidth={iconStroke} /></FormatButton>
<FormatButton title="Remove formatting" onClick={() => execFormat("removeFormat")}><RemoveFormatting size={14} strokeWidth={iconStroke} /></FormatButton>
</div>
</div>
@ -680,11 +870,23 @@ export default function ComposeModal({
<button type="button" className="compose-icon-btn" title="Insert image" onClick={() => imageInputRef.current?.click()}>
<Image size={16} strokeWidth={iconStroke} />
</button>
<button type="button" className="compose-icon-btn" title="Insert link" onClick={insertLink}>
<button
type="button"
className="compose-icon-btn"
title="Insert link"
onMouseDown={(event) => event.preventDefault()}
onClick={openLinkDialog}
>
<Link size={16} strokeWidth={iconStroke} />
</button>
<div className="compose-emoji-wrap">
<button type="button" className="compose-icon-btn" title="Emoji" onClick={() => setShowEmoji((v) => !v)}>
<button
type="button"
className="compose-icon-btn"
title="Emoji"
onMouseDown={(event) => event.preventDefault()}
onClick={() => setShowEmoji((v) => !v)}
>
<Smile size={16} strokeWidth={iconStroke} />
</button>
{showEmoji && (

View file

@ -1,6 +1,7 @@
import { ArrowLeft } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { getHandoffConversation, retryHandoffDelivery } from "../api";
import { formatClockTime } from "../utils/datetime";
function HandoffMessageBody({ message }) {
const [showQuoted, setShowQuoted] = useState(false);
@ -25,14 +26,6 @@ function HandoffMessageBody({ message }) {
);
}
function formatTime(dateValue) {
if (!dateValue) return "";
return new Date(dateValue).toLocaleString(undefined, {
hour: "numeric",
minute: "2-digit",
});
}
export default function HandoffConversation({ handoffId, onBack, onOpenExternal }) {
const token = sessionStorage.getItem("agmail_access_token");
const [conversation, setConversation] = useState(null);
@ -111,7 +104,7 @@ export default function HandoffConversation({ handoffId, onBack, onOpenExternal
? "HUMAN OWNER"
: "GENTIC → HUMAN"}
</strong>
<time>{formatTime(message.event_at)}</time>
<time>{formatClockTime(message.event_at)}</time>
</header>
<HandoffMessageBody message={message} />
{message.role === "human" && message.relay_status === "SENT" && (

View file

@ -1,20 +1,21 @@
import { ArrowLeftRight } from "lucide-react";
import { ArrowLeftRight, ChevronRight } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { listHandoffConversations } from "../api";
import { formatRelativeTime } from "../utils/datetime";
function formatRelativeTime(dateValue) {
if (!dateValue) return "";
const date = new Date(dateValue);
const diffMs = Date.now() - date.getTime();
const minutes = Math.floor(diffMs / 60000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
const days = Math.floor(hours / 24);
if (days === 1) return "Yesterday";
if (days < 7) return `${days} days ago`;
return date.toLocaleDateString();
function ownerDisplayName(item) {
const named = (item.human_name || "").trim();
if (named) return named;
const local = (item.human_email || "").split("@")[0];
if (!local) return item.rule_name || "Human Owner";
return local.replace(/[._-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
function previewSnippet(value) {
return (value || "")
.replace(/\s+/g, " ")
.trim()
.replace(/^(sender|responder|human|agent)\s*:\s*/i, "");
}
export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
@ -22,6 +23,7 @@ export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const isInbox = mailbox === "handoff_inbox";
const load = useCallback(async () => {
setLoading(true);
@ -42,11 +44,14 @@ export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
return (
<div className="handoff-inbox">
<p className="handoff-inbox-lead">
{mailbox === "handoff_inbox"
? "Replies received from Human Owners"
: "Messages mirrored from Gentic to Human Owners"}
</p>
<header className="handoff-section-head">
<h2>{isInbox ? "Handoff Inbox" : "Handoff Outbox"}</h2>
<p>
{isInbox
? "Replies received from Human Owners."
: "Messages mirrored from Gentic to Human Owners."}
</p>
</header>
{error && <p className="form-error" role="alert">{error}</p>}
{loading ? (
<p className="people-table-empty">Loading handoffs…</p>
@ -55,7 +60,7 @@ export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
<div className="human-handoff-empty-icon" aria-hidden="true">
<ArrowLeftRight size={24} strokeWidth={1.75} />
</div>
<h2>{mailbox === "handoff_inbox" ? "No Human replies yet" : "No mirrored messages yet"}</h2>
<h2>{isInbox ? "No Human replies yet" : "No mirrored messages yet"}</h2>
<p>
When the Agent hands off a customer conversation, it appears here as a private
Agent ↔ Human thread — separate from your normal Inbox.
@ -63,29 +68,41 @@ export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) {
</div>
) : (
<div className="handoff-inbox-list">
{items.map((item) => (
<button
key={item.id}
type="button"
className="handoff-inbox-row"
onClick={() => onSelect(item.id)}
>
<div className="handoff-inbox-row-main">
<strong>{item.rule_name} · {item.external_subject || "(No subject)"}</strong>
<span className="handoff-inbox-row-party">
{item.external_sender} ↔ {item.human_email}
</span>
{item.snippet && <p className="handoff-inbox-row-snippet">&ldquo;{item.snippet}&rdquo;</p>}
</div>
<div className="handoff-inbox-row-meta">
<time>{formatRelativeTime(item.updated_at)}</time>
<span>{item.message_count} message{item.message_count === 1 ? "" : "s"}</span>
<span className={`handoff-inbox-status status-${item.status.toLowerCase()}`}>
{item.status_label}
</span>
</div>
</button>
))}
{items.map((item) => {
const preview = previewSnippet(item.snippet);
return (
<button
key={item.id}
type="button"
className="handoff-inbox-row"
onClick={() => onSelect(item.id)}
>
<div className="handoff-inbox-row-main">
<p className="handoff-inbox-row-title">
<strong>{ownerDisplayName(item)}</strong>
<span> · </span>
<em>{item.external_subject || "(No subject)"}</em>
</p>
<span className="handoff-inbox-row-party">
{item.external_sender}
<span aria-hidden="true"> ↔ </span>
{item.human_email}
</span>
{preview && <p className="handoff-inbox-row-snippet">{preview}</p>}
</div>
<div className="handoff-inbox-row-meta">
<time>{formatRelativeTime(item.updated_at)}</time>
<span>
{item.message_count} message{item.message_count === 1 ? "" : "s"}
</span>
<span className={`handoff-inbox-status status-${item.status.toLowerCase()}`}>
{item.status_label}
</span>
</div>
<ChevronRight className="handoff-inbox-row-chevron" size={14} strokeWidth={1.75} />
</button>
);
})}
</div>
)}
</div>

View file

@ -16,8 +16,10 @@ export default function HumanHandoff({ onOpenExternal }) {
<section className="human-handoff-page">
<header className="human-handoff-head">
<div>
<h1>Human Handoff</h1>
<p>Private mirrored conversations between your Agent and Human Owners.</p>
<h1 className="handoff-page-title">Human Handoff</h1>
<p className="handoff-page-lead">
Manage conversations that your Agent has handed over to a Human Owner.
</p>
</div>
</header>
<nav className="human-handoff-tabs" aria-label="Human Handoff">

View file

@ -21,6 +21,8 @@ import AgentSpark from "./AgentSpark";
import SenderAvatar from "./SenderAvatar";
import { addressText, senderDisplayName } from "../utils/senderAvatar";
import { attachmentCount } from "../utils/inboxSignals";
import { visibleMessageHtml } from "../utils/emailReply";
import { formatMailDetailTime } from "../utils/datetime";
const TOOLBAR_ICON = { size: 16, strokeWidth: 1.7 };
const ACTION_ICON = { size: 14, strokeWidth: 1.7 };
@ -44,12 +46,16 @@ function messagePreview(message) {
}
function messageBody(email) {
const preview = messagePreview(email);
if (preview) return { mode: "text", value: preview };
const text = (email?.body_text || email?.text_body || email?.plain_text || "").trim();
if (text) return { mode: "text", value: text };
const html = (email?.body_html || "").trim();
const html = visibleMessageHtml(email?.body_html || "");
if (html) return { mode: "html", value: html };
const text = (
email?.new_reply_content
|| email?.body_text
|| email?.text_body
|| email?.plain_text
|| ""
).trim();
if (text) return { mode: "text", value: text };
const snippet = (email?.snippet || "").trim();
if (snippet) return { mode: "text", value: snippet };
return { mode: "text", value: "This message has no readable content." };
@ -89,20 +95,9 @@ function MessageBodyContent({ message }) {
}
function detailTime(email) {
const value = email?.received_at || email?.sent_at || email?.created_at || email?.updated_at;
if (!value) return { absolute: "", relative: "" };
const date = new Date(value);
if (Number.isNaN(date.getTime())) return { absolute: "", relative: "" };
const absolute = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
const diffMs = Date.now() - date.getTime();
const diffHours = Math.max(1, Math.round(diffMs / (1000 * 60 * 60)));
const relative = diffHours < 24 ? `${diffHours}h ago` : `${Math.round(diffHours / 24)}d ago`;
return { absolute, relative };
return formatMailDetailTime(
email?.received_at || email?.sent_at || email?.created_at || email?.updated_at,
);
}
function senderIdentity(email) {

View file

@ -11,28 +11,10 @@ import AgentSpark from "./AgentSpark";
import SenderAvatar from "./SenderAvatar";
import { senderDisplayName } from "../utils/senderAvatar";
import { attachmentCount, emailLabels } from "../utils/inboxSignals";
import { formatMailboxTime } from "../utils/datetime";
const ICON = { size: 15, strokeWidth: 1.75 };
function formatTimestamp(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const now = new Date();
const sameDay = date.toDateString() === now.toDateString();
if (sameDay) {
return new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(date);
}
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}),
}).format(date);
}
export default function MailRow({
email,
selected,
@ -129,7 +111,7 @@ export default function MailRow({
{files > 0 && <Paperclip size={14} strokeWidth={1.75} />}
</span>
<div className="mailbox-row-rail">
<time className="mailbox-time" dateTime={timestamp || undefined}>{formatTimestamp(timestamp)}</time>
<time className="mailbox-time" dateTime={timestamp || undefined}>{formatMailboxTime(timestamp)}</time>
<div className="mailbox-hover-actions" aria-label="Message actions">
<button type="button" title="Archive" aria-label="Archive" onClick={(event) => stop(event, () => onArchive?.(email))}>
<Archive {...ICON} />

View file

@ -14,6 +14,7 @@ const ICON = { size: 16, strokeWidth: 1.75 };
export default function MailboxSelectionBar({
count,
total,
hideSelectAll = false,
onToggleAll,
onArchive,
onSpam,
@ -30,12 +31,14 @@ export default function MailboxSelectionBar({
return (
<div className="mailbox-selection-bar" role="toolbar" aria-label="Bulk message actions">
<div className="mailbox-selection-meta">
<input
type="checkbox"
checked={allSelected}
aria-label={allSelected ? "Deselect all messages" : "Select all messages"}
onChange={onToggleAll}
/>
{!hideSelectAll && (
<input
type="checkbox"
checked={allSelected}
aria-label={allSelected ? "Deselect all messages" : "Select all messages"}
onChange={onToggleAll}
/>
)}
<span>{count} selected</span>
</div>
<div className="mailbox-selection-actions">

View file

@ -6,8 +6,8 @@ import {
CircleHelp,
Filter,
LayoutGrid,
List,
Mail,
MoreHorizontal,
RefreshCw,
Search,
Settings,
@ -46,7 +46,14 @@ import {
} from "../utils/inboxSignals";
import { buildReplyBodyHtml } from "../utils/emailReply";
const PAGE_SIZE = 25;
const PAGE_SIZE_KEY = "agmail_page_size";
const PAGE_SIZE_OPTIONS = [25, 50, 100];
function readPageSize() {
const stored = Number(window.localStorage.getItem(PAGE_SIZE_KEY));
return PAGE_SIZE_OPTIONS.includes(stored) ? stored : 50;
}
const CATEGORY_TABS = [
{ name: "Primary", icon: LayoutGrid },
{ name: "Updates", icon: Bell },
@ -57,10 +64,11 @@ const CATEGORY_TABS = [
];
function resultPage(result) {
if (Array.isArray(result)) return { items: result, nextCursor: null };
if (Array.isArray(result)) return { items: result, nextCursor: null, total: result.length };
return {
items: result.items || result.emails || result.results || [],
nextCursor: result.next_cursor || result.nextCursor || null,
total: Number(result.total || 0),
};
}
@ -112,6 +120,9 @@ export default function MailboxView({
const token = sessionStorage.getItem("agmail_access_token");
const [emails, setEmails] = useState([]);
const [nextCursor, setNextCursor] = useState(null);
const [total, setTotal] = useState(0);
const [pageSize, setPageSize] = useState(readPageSize);
const [pageMenuOpen, setPageMenuOpen] = useState(false);
const [cursor, setCursor] = useState(null);
const [cursorHistory, setCursorHistory] = useState([]);
const [selected, setSelected] = useState(null);
@ -140,7 +151,7 @@ export default function MailboxView({
const result = await listThreads(
{
mailbox,
limit: PAGE_SIZE,
limit: pageSize,
cursor,
q: filters.query.trim(),
is_read: filters.read === "all" ? undefined : filters.read === "read",
@ -153,11 +164,12 @@ export default function MailboxView({
const page = resultPage(result);
setEmails(page.items.map(threadToRow));
setNextCursor(page.nextCursor);
setTotal(page.total);
setStatus({ loading: false, error: "" });
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}, [cursor, filters, mailbox, token]);
}, [cursor, filters, mailbox, pageSize, token]);
useEffect(() => {
const timeout = window.setTimeout(load, filters.query ? 250 : 0);
@ -250,6 +262,22 @@ export default function MailboxView({
});
}, [emails, onStats]);
useEffect(() => {
setCursor(null);
setCursorHistory([]);
setChecked({});
setPageMenuOpen(false);
}, [mailbox]);
useEffect(() => {
if (!pageMenuOpen) return undefined;
function closeMenu() {
setPageMenuOpen(false);
}
window.addEventListener("click", closeMenu);
return () => window.removeEventListener("click", closeMenu);
}, [pageMenuOpen]);
function changeFilter(name, value) {
setFilters((current) => ({ ...current, [name]: value }));
setCursor(null);
@ -580,6 +608,7 @@ export default function MailboxView({
setCursor(nextCursor);
setSelected(null);
setDetail(null);
setChecked({});
}
function previousPage() {
@ -589,11 +618,30 @@ export default function MailboxView({
setCursor(previous);
setSelected(null);
setDetail(null);
setChecked({});
}
function changePageSize(size) {
if (size === pageSize) {
setPageMenuOpen(false);
return;
}
window.localStorage.setItem(PAGE_SIZE_KEY, String(size));
setPageSize(size);
setCursor(null);
setCursorHistory([]);
setChecked({});
setPageMenuOpen(false);
}
const initials = `${account?.first_name?.[0] || ""}${account?.last_name?.[0] || ""}`.toUpperCase();
const rangeStart = listedEmails.length ? cursorHistory.length * PAGE_SIZE + 1 : 0;
const rangeEnd = cursorHistory.length * PAGE_SIZE + listedEmails.length;
const rangeStart = emails.length ? cursorHistory.length * pageSize + 1 : 0;
const rangeEnd = emails.length ? rangeStart + emails.length - 1 : 0;
const rangeLabel = emails.length === 0
? `0–0 of ${total}`
: `${rangeStart}–${rangeEnd} of ${total || rangeEnd}`;
const allSelected = listedEmails.length > 0 && selectedCount === listedEmails.length;
const someSelected = selectedCount > 0 && !allSelected;
return (
<section className="mailbox-workspace">
@ -652,10 +700,85 @@ export default function MailboxView({
<div className="mailbox-main-column">
<div className={`mailbox-shell${selected ? " detail-visible" : ""}`}>
<div className="mailbox-list-pane">
<div className="mailbox-list-bar" role="toolbar" aria-label="Mailbox controls">
<div className="mailbox-list-bar-actions">
<label className="mailbox-select-all">
<input
type="checkbox"
checked={allSelected}
ref={(node) => {
if (node) node.indeterminate = someSelected;
}}
aria-label={allSelected ? "Deselect all messages" : "Select all messages"}
onChange={toggleSelectAll}
/>
</label>
<button
type="button"
className={status.loading ? "is-busy" : ""}
title="Refresh"
aria-label="Refresh"
onClick={load}
>
<RefreshCw size={16} strokeWidth={1.75} />
</button>
<div className="mailbox-page-size-wrap" onClick={(event) => event.stopPropagation()}>
<button
type="button"
title="More options"
aria-label="More options"
aria-expanded={pageMenuOpen}
onClick={() => setPageMenuOpen((open) => !open)}
>
<MoreHorizontal size={16} strokeWidth={1.75} />
</button>
{pageMenuOpen && (
<div className="mailbox-page-size-menu" role="menu">
<p>Emails per page</p>
{PAGE_SIZE_OPTIONS.map((size) => (
<button
key={size}
type="button"
role="menuitemradio"
aria-checked={pageSize === size}
className={pageSize === size ? "active" : ""}
onClick={() => changePageSize(size)}
>
{size}
</button>
))}
</div>
)}
</div>
</div>
<div className="mailbox-list-bar-page">
<span className="mailbox-pagination-range">{rangeLabel}</span>
<button
type="button"
title="Newer"
aria-label="Previous page"
disabled={!cursorHistory.length || status.loading}
onClick={previousPage}
>
<ChevronLeft size={18} strokeWidth={1.75} />
</button>
<button
type="button"
title="Older"
aria-label="Next page"
disabled={!nextCursor || status.loading}
onClick={nextPage}
>
<ChevronRight size={18} strokeWidth={1.75} />
</button>
</div>
</div>
{selectedCount > 0 && (
<MailboxSelectionBar
count={selectedCount}
total={listedEmails.length}
hideSelectAll
onToggleAll={toggleSelectAll}
onArchive={() => runBulkAction("archive")}
onSpam={() => runBulkAction("spam")}
@ -755,32 +878,6 @@ export default function MailboxView({
))}
</div>
{!status.loading && (
<footer className="mailbox-pagination">
<span className="mailbox-pagination-range">
{listedEmails.length === 0
? "0 messages"
: `${rangeStart}–${rangeEnd}${nextCursor ? "" : ` · ${rangeEnd} loaded`}`}
</span>
<div className="mailbox-pagination-controls">
{listedEmails.length > 0 && (
<>
<button type="button" title="Previous page" aria-label="Previous page" disabled={!cursorHistory.length} onClick={previousPage}>
<ChevronLeft size={16} strokeWidth={1.75} />
</button>
<button type="button" title="Next page" aria-label="Next page" disabled={!nextCursor} onClick={nextPage}>
<ChevronRight size={16} strokeWidth={1.75} />
</button>
</>
)}
</div>
<div className="mailbox-pagination-density" aria-label="List density">
<button type="button" className="active" title="Comfortable density" aria-label="Comfortable density"><List size={15} strokeWidth={1.75} /></button>
<button type="button" title="Compact density" aria-label="Compact density"><LayoutGrid size={15} strokeWidth={1.75} /></button>
</div>
</footer>
)}
<MailDetail
email={detail}
thread={threadDetail}

View file

@ -16,6 +16,7 @@ import greenPin from "../data/green.png";
import pinkPin from "../data/pink.png";
import yellowPin from "../data/yellow.png";
import { senderDisplayName } from "../utils/senderAvatar";
import { formatAbsoluteDateTime } from "../utils/datetime";
const NOTE_COLORS = ["yellow", "green", "blue", "pink"];
const PIN_IMAGES = {
@ -25,18 +26,6 @@ const PIN_IMAGES = {
pink: pinkPin,
};
function formatNoteTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
}
function noteMatchesContext(note, contextEmail) {
const needle = senderDisplayName(contextEmail).toLowerCase();
if (!needle) return false;
@ -84,7 +73,7 @@ function NoteCard({ note, onDelete, menuOpen, onMenuToggle }) {
<span key={tag}>{tag}</span>
))}
</div>
<time dateTime={note.updated_at}>{formatNoteTime(note.updated_at)}</time>
<time dateTime={note.updated_at}>{formatAbsoluteDateTime(note.updated_at)}</time>
</footer>
</article>
);

View file

@ -527,26 +527,169 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.security-toggle span { position:absolute; inset:0; border-radius:999px; background:#D9D5E0; transition:background .2s; cursor:pointer; }
.security-toggle span::after { content:""; position:absolute; top:3px; left:3px; width:18px; height:18px; border-radius:50%; background:#fff; box-shadow:0 1px 3px rgba(20,18,30,0.18); transition:transform .2s; }
.security-toggle input:checked + span { background:#7357F6; }
.human-handoff-page { max-width:910px; margin:0 auto; padding:8px 4px 32px; }
.human-handoff-head { margin-bottom:16px; display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
.human-handoff-head h1 { margin:0 0 4px; font:700 26px/1.15 "Manrope"; color:#171424; }
.human-handoff-head p { margin:0; max-width:700px; color:#77758a; font-size:12px; line-height:1.5; }
.human-handoff-tabs { margin-top:16px; display:inline-flex; gap:6px; padding:4px; border:1px solid #ece9f2; border-radius:10px; background:#faf9fc; }
.human-handoff-tabs button { height:32px; padding:0 14px; border:0; border-radius:8px; background:transparent; color:#6f6b7c; font-size:12px; font-weight:600; }
.human-handoff-tabs button.active { background:#fff; color:#171424; box-shadow:0 1px 4px rgba(20,18,30,0.08); }
.handoff-inbox, .handoff-settings, .handoff-conversation { margin-top:16px; }
.human-handoff-page { max-width:900px; margin:0; padding:8px 4px 32px; }
.human-handoff-head { margin:0; }
.handoff-page-title,
.human-handoff-head h1.handoff-page-title {
margin:0;
font-family:"DM Sans",sans-serif;
font-size:26px;
line-height:1.2;
font-weight:700;
letter-spacing:-0.015em;
color:#171424;
white-space:nowrap;
font-stretch:normal;
font-kerning:normal;
font-synthesis:none;
text-shadow:none;
transform:none;
}
.handoff-page-lead,
.human-handoff-head p.handoff-page-lead {
margin:5px 0 0;
max-width:640px;
font-size:12px;
line-height:1.45;
font-weight:400;
color:#77758A;
}
.human-handoff-tabs {
margin-top:24px;
height:38px;
padding:3px;
display:inline-flex;
align-items:center;
gap:0;
border:1px solid #E6E2EA;
border-radius:9px;
background:#FAF9FB;
}
.human-handoff-tabs button {
height:30px;
padding:0 14px;
border:0;
border-radius:6px;
background:transparent;
color:#777286;
font-size:11px;
font-weight:550;
}
.human-handoff-tabs button.active {
background:#FFFFFF;
color:#252130;
box-shadow:0 1px 2px rgba(20,18,30,.06);
}
.handoff-inbox, .handoff-settings, .handoff-conversation { margin-top:22px; }
.handoff-section-head { margin:0 0 12px; }
.handoff-section-head h2 {
margin:0;
font-size:13px;
font-weight:650;
line-height:1.3;
color:#292536;
}
.handoff-section-head p {
margin:3px 0 0;
font-size:10.5px;
font-weight:400;
line-height:1.4;
color:#8A8696;
}
.handoff-inbox-lead { margin:0 0 14px; color:#77758a; font-size:12px; }
.handoff-inbox-list { display:grid; gap:10px; }
.handoff-inbox-row { width:100%; padding:16px 18px; display:grid; grid-template-columns:1fr auto; gap:16px; align-items:start; border:1px solid #ece9f2; border-radius:12px; background:#fff; text-align:left; cursor:pointer; box-shadow:0 6px 20px rgba(20,18,30,0.03); }
.handoff-inbox-row:hover { border-color:#ddd4ff; background:#fcfbff; }
.handoff-inbox-row-main strong { display:block; margin-bottom:4px; font:650 14px/1.3 "DM Sans"; color:#171424; }
.handoff-inbox-row-party { color:#6d5bd0; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.3px; }
.handoff-inbox-row-snippet { margin:8px 0 0; color:#5a5562; font-size:12px; line-height:1.45; }
.handoff-inbox-row-meta { min-width:120px; display:grid; gap:4px; justify-items:end; color:#77758a; font-size:11px; }
.handoff-inbox-status { height:22px; padding:0 8px; display:inline-flex; align-items:center; border-radius:999px; background:#f1eff3; color:#716d7a; font-size:10px; font-weight:600; }
.handoff-inbox-status.status-waiting_for_human { background:#fff6e8; color:#b26a00; }
.handoff-inbox-list {
overflow:hidden;
border:1px solid #E7E4EA;
border-radius:11px;
background:#FFFFFF;
box-shadow:0 1px 2px rgba(20,18,30,.02);
}
.handoff-inbox-row {
width:100%;
min-height:82px;
padding:14px 16px;
display:grid;
grid-template-columns:minmax(0,1fr) 120px 16px;
gap:12px;
align-items:center;
border:0;
border-bottom:1px solid #EFEDF1;
border-radius:0;
background:#fff;
text-align:left;
cursor:pointer;
box-shadow:none;
transition:background 120ms ease;
}
.handoff-inbox-row:last-child { border-bottom:0; }
.handoff-inbox-row:hover { background:#FAF9FC; }
.handoff-inbox-row-main { min-width:0; }
.handoff-inbox-row-title {
margin:0;
font-size:13px;
line-height:1.3;
color:#211E2C;
}
.handoff-inbox-row-title strong { font-weight:650; }
.handoff-inbox-row-title span { font-weight:500; color:#8A8696; }
.handoff-inbox-row-title em { font-style:normal; font-weight:500; color:#211E2C; }
.handoff-inbox-row-party {
display:block;
margin-top:3px;
color:#77758A;
font-size:9.5px;
font-weight:500;
text-transform:none;
letter-spacing:0;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.handoff-inbox-row-party span { color:#7564D9; }
.handoff-inbox-row-snippet {
margin:4px 0 0;
max-width:100%;
color:#77758A;
font-size:11px;
font-weight:400;
line-height:1.4;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.handoff-inbox-row-meta {
width:120px;
display:flex;
flex-direction:column;
align-items:flex-end;
color:#8A8696;
font-size:10px;
font-weight:400;
}
.handoff-inbox-row-meta time { font-size:10px; font-weight:400; color:#8A8696; }
.handoff-inbox-row-meta>span:not(.handoff-inbox-status) {
margin-top:2px;
font-size:9.5px;
font-weight:400;
color:#A09CAA;
}
.handoff-inbox-status {
height:20px;
margin-top:7px;
padding:0 8px;
display:inline-flex;
align-items:center;
border-radius:999px;
background:#F2F1F5;
color:#686475;
font-size:9px;
font-weight:600;
}
.handoff-inbox-status.status-waiting_for_human { background:#FFF5DF; color:#A96813; }
.handoff-inbox-status.status-human_replied { background:#edf4ff; color:#2f67c5; }
.handoff-inbox-status.status-agent_replied { background:#edf9f4; color:#11966a; }
.handoff-inbox-row-chevron { color:#C6C2CE; opacity:0; justify-self:end; transition:opacity 120ms ease; }
.handoff-inbox-row:hover .handoff-inbox-row-chevron { opacity:1; }
.handoff-back-btn { margin-bottom:14px; height:32px; padding:0 10px; display:inline-flex; align-items:center; gap:6px; border:0; border-radius:8px; background:transparent; color:#6d5bd0; font-size:12px; font-weight:600; }
.handoff-back-btn:hover { background:#f6f2ff; }
.handoff-conversation-head h2 { margin:0 0 10px; font:700 22px/1.2 "Manrope"; color:#171424; }
@ -710,10 +853,26 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.mailbox-empty-link { border:0; background:transparent; color:#7b67c8; font:600 12px "DM Sans"; cursor:pointer; }
.mailbox-empty-link:hover { color:#5a3fe0; }
.mailbox-empty-badge { margin:14px 0 0; display:inline-flex; align-items:center; gap:6px; color:#3d8a5d; font:500 11px "DM Sans"; }
.agent-ready-card { margin:0 0 12px; padding:12px 13px; border:1px solid rgba(118,86,232,0.12); border-radius:12px; background:#faf8ff; }
.agent-ready-card { margin:0 0 12px; padding:10px 12px; border:1px solid rgba(118,86,232,0.07); border-radius:8px; background:#fcfbfe; }
.agent-ready-card p { margin:0; color:#77758A; font:400 10px/15px "DM Sans"; }
.agent-ready-lead { margin:0 0 6px !important; display:flex; align-items:center; gap:6px; color:#5a3fe0 !important; font:600 11px/14px "DM Sans" !important; }
.agent-ready-lead { margin:0 0 6px !important; display:flex; align-items:center; gap:6px; color:#6d6680 !important; font:600 11px/14px "DM Sans" !important; }
.agent-quick-btn:disabled { opacity:.45; cursor:not-allowed; }
.mailbox-list-bar { min-height:48px; padding:0 8px 0 12px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--border-subtle); background:var(--surface); }
.mailbox-list-bar-actions,.mailbox-list-bar-page { display:flex; align-items:center; gap:2px; }
.mailbox-select-all { width:40px; height:40px; display:grid; place-items:center; }
.mailbox-select-all input { width:16px; height:16px; margin:0; accent-color:#5f6368; }
.mailbox-list-bar-actions>button,.mailbox-page-size-wrap>button,.mailbox-list-bar-page button { width:40px; height:40px; display:grid; place-items:center; border:0; border-radius:50%; background:transparent; color:#5f6368; }
.mailbox-list-bar-actions>button:hover,.mailbox-page-size-wrap>button:hover,.mailbox-list-bar-page button:hover:not(:disabled) { background:#f1f3f4; }
.mailbox-list-bar-page button:disabled { opacity:.35; cursor:default; }
.mailbox-list-bar-actions>button.is-busy svg { animation:mailbox-spin .8s linear infinite; }
.mailbox-pagination-range { margin-right:8px; color:#5f6368; font-size:12px; font-weight:500; white-space:nowrap; }
.mailbox-page-size-wrap { position:relative; }
.mailbox-page-size-menu { position:absolute; top:42px; left:0; z-index:8; min-width:168px; padding:8px 0; border:1px solid #ece9f2; border-radius:10px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,.12); }
.mailbox-page-size-menu p { margin:0; padding:6px 14px 8px; color:#6f6b7c; font-size:11px; font-weight:700; letter-spacing:.02em; text-transform:uppercase; }
.mailbox-page-size-menu button { width:100%; height:36px; padding:0 14px; display:flex; align-items:center; justify-content:flex-start; border:0; border-radius:0; background:transparent; color:#241f2b; font-size:13px; }
.mailbox-page-size-menu button:hover,.mailbox-page-size-menu button.active { background:#f6f3ff; color:#5a3fe0; }
@keyframes mailbox-spin { to { transform:rotate(360deg); } }
.mailbox-pagination { min-height:44px; padding:var(--space-2) var(--space-4); display:grid; grid-template-columns:1fr auto auto; align-items:center; gap:var(--space-3); border-top:1px solid var(--border-subtle); background:var(--surface); color:#8d8792; font-size:var(--text-micro); }
.mailbox-pagination-range { font-weight:500; }
.mailbox-pagination-controls,.mailbox-pagination-density { display:flex; align-items:center; gap:var(--space-1); }
@ -788,74 +947,86 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.mailbox-shell.detail-visible .mailbox-list-pane { display:block; min-height:0; height:auto; background:var(--canvas); border-radius:0; padding:0 0 24px; }
.mail-detail { min-width:0; display:none; position:relative; z-index:1; }
.mail-detail.open { display:block; }
.mail-detail-reader { --reader-x0:32px; --reader-avatar:38px; --reader-gap:8px; --reader-x2:calc(var(--reader-x0) + var(--reader-avatar) + var(--reader-gap)); width:100%; height:auto; min-height:0; background:#fff; border:1px solid rgba(25,22,40,.05); border-radius:12px; box-shadow:0 1px 2px rgba(20,18,30,.02),0 6px 18px rgba(20,18,30,.025); overflow:hidden; }
.mail-detail-toolbar { height:48px; padding:0 24px; display:flex; align-items:center; gap:2px; border-bottom:1px solid #ECE9EE; background:#fff; }
.mail-detail-reader { --reader-x0:32px; --reader-avatar:38px; --reader-gap:8px; --reader-x2:calc(var(--reader-x0) + var(--reader-avatar) + var(--reader-gap)); width:100%; height:auto; min-height:0; background:#fff; border:1px solid rgba(25,22,40,.055); border-radius:8px; box-shadow:none; overflow:hidden; }
.mail-detail-toolbar { height:48px; padding:0 var(--reader-x0); display:flex; align-items:center; gap:2px; border-bottom:1px solid #ECE9EE; background:#fff; }
.mail-detail-toolbar-group { display:flex; align-items:center; gap:2px; }
.mail-detail-toolbar-sep { width:1px; height:18px; margin:0 10px; background:#ECE9EE; flex-shrink:0; }
.mail-detail-toolbar button { width:32px; height:32px; padding:0; display:grid; place-items:center; border:0; border-radius:7px; background:transparent; color:#6f6b7c; }
.mail-detail-toolbar button { width:32px; height:32px; padding:0; display:grid; place-items:center; border:0; border-radius:6px; background:transparent; color:#6f6b7c; }
.mail-detail-toolbar button svg { width:16px; height:16px; }
.mail-detail-toolbar button:hover { background:#f5f4f2; color:#241f2b; }
.mail-detail-toolbar button.destructive { color:#6f6b7c; }
.mail-detail-toolbar button.destructive:hover { background:#fff0ed; color:#e94d3d; }
.mail-detail-sheet { height:auto; min-height:0; margin:0; background:#fff; }
.mail-detail-state { min-height:160px; padding:32px; display:grid; place-items:center; color:#8d8891; font-size:12px; text-align:center; background:#fff; border:1px solid rgba(25,22,40,.055); border-radius:12px; }
.mail-detail-state { min-height:160px; padding:32px; display:grid; place-items:center; color:#8d8891; font-size:12px; text-align:center; background:#fff; border:1px solid rgba(25,22,40,.055); border-radius:8px; }
.mail-detail-state.error { color:#a94f40; }
.mail-detail-subject-row { min-height:42px; padding:22px var(--reader-x0) 0; display:flex; align-items:center; justify-content:space-between; gap:16px; width:100%; box-sizing:border-box; }
.mail-detail-subject-row { min-height:32px; padding:16px var(--reader-x0) 0; display:flex; align-items:center; justify-content:space-between; gap:16px; width:100%; box-sizing:border-box; }
.mail-detail-subject-main { min-width:0; display:flex; flex-wrap:wrap; align-items:center; gap:8px; }
.mail-detail-subject-row h1 { margin:0; overflow-wrap:anywhere; font:650 22px/30px "Manrope"; letter-spacing:-.4px; color:#171424; }
.mail-detail-subject-row h1 { margin:0; overflow-wrap:anywhere; font-family:"DM Sans",sans-serif; font-size:20px; font-weight:650; line-height:1.25; letter-spacing:-.015em; color:#171424; }
.mail-detail-labels { display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
.mail-detail-label-pill { height:20px; padding:0 7px; display:inline-flex; align-items:center; border-radius:5px; background:#f1eff3; color:#716d7a; font-size:9.5px; font-weight:550; }
.mail-detail-label-pill { height:20px; padding:0 7px; display:inline-flex; align-items:center; border-radius:5px; background:#f1eff3; color:#77758a; font-size:10.5px; font-weight:500; }
.mail-detail-label-pill.starred { background:#fff8e8; color:#9a7228; }
.mail-detail-ai-chips { display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:6px; flex-shrink:0; }
.mail-detail-ai-chip { height:28px; padding:0 10px; display:inline-flex; align-items:center; gap:5px; border:1px solid #ece7ff; border-radius:7px; background:#fbf9ff; color:#6d5bd0; font-size:10.5px; font-weight:550; white-space:nowrap; }
.mail-detail-ai-chip:hover { background:#f3efff; border-color:#ddd4ff; }
.mail-detail-message-header { margin-top:22px; padding:0 var(--reader-x0); width:100%; box-sizing:border-box; display:grid; grid-template-columns:var(--reader-avatar) minmax(0,1fr) auto; column-gap:var(--reader-gap); align-items:start; }
.mail-detail-ai-chip { height:28px; padding:0 10px; display:inline-flex; align-items:center; gap:5px; border:1px solid rgba(115,87,246,.08); border-radius:6px; background:#faf9fc; color:#7a7390; font-size:11px; font-weight:500; white-space:nowrap; }
.mail-detail-ai-chip svg,.mail-detail-ai-chip .agent-spark { color:inherit; opacity:.8; }
.mail-detail-ai-chip:hover { background:#f5f3fa; border-color:rgba(115,87,246,.14); color:#5f5778; }
.mail-detail-message-header { margin-top:18px; padding:0 var(--reader-x0); width:100%; box-sizing:border-box; display:grid; grid-template-columns:var(--reader-avatar) minmax(0,1fr) auto; column-gap:var(--reader-gap); align-items:start; }
.mail-detail-message-header .sender-avatar { width:38px; height:38px; font-size:12px; }
.mail-detail-sender-meta { min-width:0; display:grid; gap:2px; }
.mail-detail-sender-line { display:flex; flex-wrap:wrap; align-items:baseline; gap:4px; }
.mail-detail-sender-line strong { font:650 13px/1.35 "DM Sans"; color:#171424; }
.mail-detail-sender-email { font:400 11px/1.35 "DM Sans"; color:#77758a; overflow-wrap:anywhere; }
.mail-detail-sender-meta { min-width:0; display:grid; gap:1px; }
.mail-detail-sender-line { display:flex; flex-wrap:wrap; align-items:baseline; gap:5px; }
.mail-detail-sender-line strong { font:650 12.5px/1.35 "DM Sans"; color:#171424; }
.mail-detail-sender-email { font:400 10.5px/1.35 "DM Sans"; color:#77758a; overflow-wrap:anywhere; }
.mail-detail-recipient-toggle { padding:0; display:inline-flex; align-items:center; gap:3px; border:0; background:transparent; color:#77758a; font-size:10.5px; font-weight:500; text-align:left; }
.mail-detail-recipient-toggle svg { transition:transform var(--motion-fast) var(--ease-standard); }
.mail-detail-recipient-toggle.open svg { transform:rotate(180deg); }
.mail-detail-recipient-toggle:hover { color:#4a4652; }
.mail-detail-recipient-details { margin-top:4px; padding:8px 10px; display:grid; gap:4px; border-radius:8px; background:#faf9fc; border:1px solid #f0eef5; }
.mail-detail-recipient-details small { color:#77758a; font-size:11px; line-height:1.4; }
.mail-detail-message-header-actions { display:flex; align-items:center; gap:4px; margin-left:auto; flex-shrink:0; }
.mail-detail-message-header-actions time { margin-right:6px; color:#77758a; font-size:11px; white-space:nowrap; }
.mail-detail-message-action { width:30px; height:30px; padding:0; display:inline-flex; align-items:center; justify-content:center; border:0; border-radius:7px; background:transparent; color:#6f6a73; }
.mail-detail-recipient-details { margin-top:4px; padding:8px 10px; display:grid; gap:4px; border-radius:6px; background:#faf9fc; border:1px solid #f0eef5; }
.mail-detail-recipient-details small { color:#77758a; font-size:10.5px; line-height:1.4; }
.mail-detail-message-header-actions { display:flex; align-items:center; gap:2px; margin-left:auto; flex-shrink:0; }
.mail-detail-message-header-actions time { margin-right:6px; color:#77758a; font-size:10.5px; font-weight:400; white-space:nowrap; }
.mail-detail-message-action { width:32px; height:32px; padding:0; display:inline-flex; align-items:center; justify-content:center; border:0; border-radius:6px; background:transparent; color:#6f6a73; }
.mail-detail-message-action svg { width:16px; height:16px; }
.mail-detail-message-action:hover { background:#f3f1f5; color:#241f2b; }
.mail-detail-message-action.active { color:#d4a017; }
.mail-detail-message-content { margin-top:22px; padding:0 var(--reader-x0) 0 var(--reader-x2); width:100%; box-sizing:border-box; }
.mail-detail-message-content.is-html { padding-left:var(--reader-x0); }
.mail-detail-body { color:#292536; }
.mail-detail-body.is-text { max-width:760px; font-size:13px; line-height:1.58; }
.mail-detail-body-text { white-space:pre-line; overflow-wrap:anywhere; }
.mail-detail-body.is-html { max-width:none; font-size:14px; line-height:1.55; }
.mail-detail-body-html { white-space:normal; }
.mail-detail-body-html p { margin:0 0 12px; }
.mail-detail-message-content { margin-top:14px; padding:0 var(--reader-x0) 0 var(--reader-x2); width:100%; box-sizing:border-box; }
.mail-detail-message-content.is-html { padding-left:var(--reader-x2); }
.mail-detail-body { max-width:760px; color:#241f2b; font-size:13px; line-height:1.55; }
.mail-detail-body-text { white-space:pre-wrap; overflow-wrap:anywhere; }
.mail-detail-body.is-html { max-width:none; }
.mail-detail-body-html { white-space:normal; overflow-wrap:anywhere; }
.mail-detail-body-html p,.mail-detail-body-html h1,.mail-detail-body-html h2,.mail-detail-body-html h3 { margin:0 0 .55em; }
.mail-detail-body-html div { margin:0; }
.mail-detail-body-html a { color:#4d74c4; text-decoration:underline; }
.mail-detail-attachments { margin-top:20px; max-width:760px; display:flex; flex-wrap:wrap; gap:10px; }
.mail-detail-body-html strong,.mail-detail-body-html b { font-weight:700; }
.mail-detail-body-html em,.mail-detail-body-html i { font-style:italic; }
.mail-detail-body-html u { text-decoration:underline; }
.mail-detail-body-html s,.mail-detail-body-html strike { text-decoration:line-through; }
.mail-detail-body-html ul,.mail-detail-body-html ol { margin:.35em 0 .55em 1.35em; padding:0; }
.mail-detail-body-html li { margin:0 0 .2em; }
.mail-detail-body-html blockquote { margin:.4em 0 .4em .8ex; padding-left:1ex; border-left:1px solid #ccc; color:#555; }
.mail-detail-attachments { margin-top:16px; max-width:760px; display:flex; flex-wrap:wrap; gap:10px; }
.mail-detail-attachment { min-width:220px; max-width:320px; padding:10px 12px; display:grid; grid-template-columns:auto 1fr auto; gap:10px; align-items:center; border:1px solid #ECE9EE; border-radius:8px; background:#fcfbfa; }
.mail-detail-attachment-icon { display:grid; place-items:center; color:#6f6b7c; }
.mail-detail-attachment strong { display:block; font-size:12px; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mail-detail-attachment small { display:block; margin-top:2px; color:#77758a; font-size:10px; }
.mail-detail-attachment small { display:block; margin-top:2px; color:#77758a; font-size:10.5px; }
.mail-detail-attachment-actions { display:flex; flex-direction:column; gap:2px; }
.mail-detail-attachment-actions button { padding:2px 4px; border:0; background:transparent; color:#6d5bd0; font-size:9px; font-weight:700; }
.mail-detail-message-footer { margin-top:24px; padding:0 0 24px; width:100%; box-sizing:border-box; }
.mail-detail-reply-bar { padding:16px var(--reader-x0) 0 var(--reader-x2); display:flex; flex-wrap:wrap; gap:8px; border-top:1px solid #f0eef2; }
.mail-detail-reply-btn { height:36px; padding:0 14px; display:inline-flex; align-items:center; gap:7px; border:1px solid #dfdde5; border-radius:8px; background:#fff; color:#241f2b; font-size:11.5px; font-weight:600; }
.mail-detail-attachment-actions button { padding:2px 4px; border:0; background:transparent; color:#7a7390; font-size:10px; font-weight:600; }
.mail-detail-message-footer { margin-top:18px; padding:0 0 18px; width:100%; box-sizing:border-box; }
.mail-detail-reply-bar { padding:18px var(--reader-x0) 0 var(--reader-x2); display:flex; flex-wrap:wrap; gap:8px; border-top:1px solid #EFEDF1; }
.mail-detail-reply-btn { height:34px; padding:0 14px; display:inline-flex; align-items:center; gap:7px; border:1px solid #e4e1e8; border-radius:8px; background:#fff; color:#241f2b; font-size:12px; font-weight:600; }
.mail-detail-reply-btn:hover { background:#f8f7f5; border-color:#d5d2da; }
.mail-detail-reply-btn.subtle { color:#5a5562; background:#fafafa; }
.mail-thread-messages.is-thread { padding:0 var(--reader-x0); }
.mail-thread-message { padding:18px 0; border-top:1px solid #f0eef2; }
.mail-thread-message:first-child { border-top:0; }
.mail-thread-message-collapsed { width:100%; padding:12px 0; display:grid; grid-template-columns:38px minmax(0,1fr) auto; gap:12px; align-items:center; border:0; border-top:1px solid #f0eef2; background:transparent; text-align:left; }
.mail-thread-messages.is-thread { padding:0; }
.mail-thread-message { padding:0; border-top:0; }
.mail-thread-messages.is-thread .mail-thread-message { border-top:1px solid #EFEDF1; }
.mail-thread-messages.is-thread .mail-thread-message:first-child { border-top:0; }
.mail-thread-messages.is-thread .mail-thread-message:not(:last-child) .mail-detail-message-content { padding-bottom:18px; }
.mail-thread-message-collapsed { width:100%; padding:10px var(--reader-x0); display:grid; grid-template-columns:var(--reader-avatar) minmax(0,1fr) auto; gap:var(--reader-gap); align-items:center; border:0; border-top:1px solid #EFEDF1; background:transparent; text-align:left; }
.mail-thread-message-collapsed:first-child { border-top:0; }
.mail-thread-message-collapsed:hover { background:#faf9fc; }
.mail-thread-message-collapsed-main { min-width:0; display:grid; gap:2px; }
.mail-thread-message-collapsed-main strong { font:600 12px/1.3 "DM Sans"; color:#171424; }
.mail-thread-message-collapsed-main strong { font:650 12.5px/1.3 "DM Sans"; color:#171424; }
.mail-thread-message-collapsed-main span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#77758a; font-size:11px; }
.mail-thread-message-collapsed time { color:#77758a; font-size:11px; white-space:nowrap; }
.mail-thread-message-collapsed time { color:#77758a; font-size:10.5px; white-space:nowrap; }
.mail-quoted-toggle { margin:10px 0 0; padding:2px 10px; border:1px solid #e8e5ee; border-radius:999px; background:#f7f6f9; color:#77758a; font-size:16px; line-height:1; letter-spacing:1px; cursor:pointer; }
.mail-quoted-toggle:hover { background:#f1eff3; color:#4a4652; }
.mail-quoted-body { margin:10px 0 0; padding:12px 14px; border-left:2px solid #e8e5ee; border-radius:0 8px 8px 0; background:#faf9fc; color:#77758a; font-size:12px; line-height:1.5; white-space:pre-wrap; overflow:auto; }
@ -863,16 +1034,16 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.agent-panel.email-context .agent-stat-grid,
.agent-panel.email-context .agent-context-block,
.agent-panel.email-context .agent-quick { display:none; }
.agent-panel.email-context .agent-panel-hello { margin-top:14px; }
.agent-panel.email-context .agent-panel-hello { margin-top:12px; }
.agent-email-context { margin:8px 0 0; padding:0; }
.agent-email-context h3 { margin:0 0 5px; font-size:10px; font-weight:700; letter-spacing:.4px; text-transform:uppercase; color:#8a8496; }
.agent-email-context h3 { margin:0 0 5px; font-size:10px; font-weight:550; letter-spacing:.04em; text-transform:uppercase; color:#9a96a4; }
.agent-email-context h3 + h3 { margin-top:12px; }
.agent-email-context p { margin:0 0 12px; font-size:12px; line-height:1.55; color:#4a4652; }
.agent-email-action-list { margin:0 0 12px; padding:0; list-style:none; display:grid; gap:7px; }
.agent-email-action-list li { display:flex; align-items:center; gap:8px; color:#4a4652; font-size:12px; line-height:1.4; }
.agent-email-action-check { width:14px; height:14px; flex-shrink:0; border:1.5px solid #c8c3d0; border-radius:4px; background:#fff; }
.agent-email-context-reply { height:32px; padding:0 12px; display:inline-flex; align-items:center; gap:6px; border:1px solid #ddd6fe; border-radius:8px; background:#faf8ff; color:#6750e8; font-size:11px; font-weight:600; }
.agent-email-context-reply:hover { background:#f3efff; }
.agent-email-action-check { width:14px; height:14px; flex-shrink:0; border:1.5px solid #d5d1da; border-radius:4px; background:#fff; }
.agent-email-context-reply { height:28px; padding:0 10px; display:inline-flex; align-items:center; gap:6px; border:1px solid rgba(115,87,246,.1); border-radius:6px; background:#faf9fc; color:#6d6680; font-size:11px; font-weight:550; }
.agent-email-context-reply:hover { background:#f5f3fa; }
.compose-dock { position:fixed; right:var(--compose-dock-right,16px); bottom:16px; z-index:45; display:flex; flex-direction:column; align-items:flex-end; gap:10px; pointer-events:none; transition:right 160ms ease-out; }
.compose-dock-expanded,.compose-dock-chips { display:flex; flex-direction:row-reverse; align-items:flex-end; gap:16px; pointer-events:auto; }
@ -884,14 +1055,14 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.compose-draft-overflow-menu { position:absolute; right:0; bottom:calc(100% + 6px); min-width:180px; padding:6px; display:grid; gap:2px; border:1px solid #ECE9EE; border-radius:8px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,.12); }
.compose-draft-overflow-menu button { width:100%; min-height:32px; padding:0 10px; border:0; border-radius:6px; background:transparent; text-align:left; font-size:11px; font-weight:600; }
.compose-draft-overflow-menu button:hover { background:#f7f4f1; }
.compose-window { --compose-pad:14px; --compose-label:30px; width:560px; min-width:500px; max-width:640px; height:560px; display:flex; flex-direction:column; overflow:hidden; background:#fff; border:1px solid rgba(24,20,35,.08); border-radius:10px; box-shadow:0 1px 2px rgba(20,18,30,.08),0 8px 24px rgba(20,18,30,.12),0 20px 48px rgba(20,18,30,.08); animation:composeSlideUp var(--motion-slow) var(--ease-standard); pointer-events:auto; flex-shrink:0; }
.compose-window { --compose-pad:14px; --compose-label:30px; width:560px; min-width:500px; max-width:640px; height:572px; display:flex; flex-direction:column; overflow:hidden; background:#fff; border:1px solid rgba(24,20,35,.08); border-radius:10px; box-shadow:0 1px 2px rgba(20,18,30,.08),0 8px 24px rgba(20,18,30,.12),0 20px 48px rgba(20,18,30,.08); animation:composeSlideUp var(--motion-slow) var(--ease-standard); pointer-events:auto; flex-shrink:0; }
.compose-window.docked { position:relative; right:auto; bottom:auto; }
.compose-window.expanded { position:fixed; left:50%; top:50%; right:auto !important; bottom:auto !important; transform:translate(-50%,-50%); width:min(900px,calc(100vw - 80px)); min-width:min(800px,calc(100vw - 80px)); max-width:960px; height:min(78vh,760px); z-index:50; }
.compose-window.focused { box-shadow:0 1px 2px rgba(20,18,30,.1),0 10px 28px rgba(20,18,30,.14),0 24px 56px rgba(20,18,30,.1); }
@keyframes composeSlideUp { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } }
.compose-window.expanded { animation:composeExpandIn var(--motion-slow) var(--ease-standard); }
@keyframes composeExpandIn { from { opacity:0; transform:translate(-50%,-48%); } to { opacity:1; transform:translate(-50%,-50%); } }
.compose-window-header { height:48px; padding:0 var(--compose-pad); display:flex; align-items:center; justify-content:space-between; gap:10px; border-bottom:1px solid #ECE9EE; background:#FCFAFB; flex-shrink:0; }
.compose-window-header { min-height:56px; height:56px; padding:10px var(--compose-pad) 6px; display:flex; align-items:center; justify-content:space-between; gap:10px; border-bottom:1px solid #ECE9EE; background:#FCFAFB; flex-shrink:0; }
.compose-window-title { min-width:0; display:flex; align-items:center; gap:8px; }
.compose-header-spark { display:inline-flex; align-items:center; justify-content:center; width:14px; height:14px; font-size:14px; line-height:1; color:#7357F6; flex-shrink:0; }
.compose-window-heading { min-width:0; display:grid; gap:1px; }
@ -902,7 +1073,7 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.compose-window-controls { display:flex; align-items:center; gap:8px; flex-shrink:0; }
.compose-window-controls button { width:28px; height:28px; display:grid; place-items:center; border:0; border-radius:6px; background:transparent; color:#6f6a73; }
.compose-window-controls button:hover { background:rgba(20,18,30,.05); color:var(--ink); }
.compose-window-body { min-height:0; flex:1; display:flex; flex-direction:column; overflow:hidden; background:#fff; }
.compose-window-body { min-height:0; flex:1; display:flex; flex-direction:column; overflow:hidden; background:#fff; position:relative; }
.compose-row { height:44px; padding:0 var(--compose-pad); display:grid; grid-template-columns:var(--compose-label) minmax(0,1fr) auto; align-items:center; gap:12px; border-bottom:1px solid #ECE9EE; flex-shrink:0; }
.compose-row-label { color:#77717a; font-size:11px; font-weight:700; }
.compose-row-main { min-width:0; }
@ -958,8 +1129,9 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.compose-agent-arrow.active { color:#7357F6; }
.compose-format-bar { height:38px; padding:0 12px; display:flex; align-items:center; gap:2px; border-bottom:1px solid #ECE9EE; background:#fff; }
.compose-format-group { display:flex; align-items:center; gap:2px; }
.compose-format-bar button { width:28px; height:28px; display:grid; place-items:center; border:0; border-radius:0; background:transparent; color:#6f6a73; }
.compose-format-bar button { width:28px; height:28px; display:grid; place-items:center; border:0; border-radius:4px; background:transparent; color:#6f6a73; }
.compose-format-bar button:hover { background:#f7f4f1; color:#c25c49; }
.compose-format-bar button.active { background:#ece7e1; color:#241f2b; }
.compose-format-divider { width:1px; height:16px; margin:0 5px; background:#ECE9EE; flex-shrink:0; }
.compose-warnings { padding:6px var(--compose-pad); border-bottom:1px solid #f0e2c8; background:#fff8ea; }
.compose-warnings p { margin:0; color:#9a7228; font-size:10px; line-height:1.4; }
@ -996,6 +1168,17 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.compose-emoji-panel { position:absolute; bottom:calc(100% + 6px); left:0; z-index:8; width:180px; padding:8px; display:grid; grid-template-columns:repeat(6,1fr); gap:4px; border:1px solid #ECE9EE; border-radius:8px; background:#fff; box-shadow:0 8px 24px rgba(20,18,30,.12); }
.compose-emoji-panel button { width:26px; height:26px; border:0; border-radius:4px; background:transparent; font-size:15px; }
.compose-emoji-panel button:hover { background:#f3efec; }
.compose-link-popover { position:absolute; left:50%; bottom:148px; z-index:12; width:min(360px,calc(100% - 32px)); transform:translateX(-50%); padding:10px; display:grid; gap:8px; border:1px solid #ece9ee; border-radius:12px; background:#fff; box-shadow:0 8px 28px rgba(20,18,30,.16); }
.compose-link-row { min-height:36px; padding:0 8px 0 10px; display:flex; align-items:center; gap:8px; border:1px solid #e4e0ea; border-radius:8px; background:#fff; color:#6f6a73; }
.compose-link-row:focus-within { border-color:#1a73e8; box-shadow:0 0 0 1px #1a73e8; }
.compose-link-row input { flex:1; min-width:0; height:34px; border:0; outline:0; background:transparent; color:var(--ink); font:500 13px/20px "DM Sans"; }
.compose-link-row input::placeholder { color:#9a95a3; }
.compose-link-apply { height:28px; padding:0 10px; border:0; border-radius:6px; background:transparent; color:#9a95a3; font:600 12px "DM Sans"; }
.compose-link-apply:disabled { color:#c5c1ca; cursor:default; }
.compose-link-row:focus-within .compose-link-apply:not(:disabled),
.compose-link-apply:not(:disabled) { color:#1a73e8; }
.compose-editor a { color:#1a73e8; text-decoration:underline; }
.compose-editor ul,.compose-editor ol { margin:4px 0 8px 1.3em; padding:0; }
body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55); pointer-events:none; }
@media (max-width:640px) {
.compose-format-divider-advanced,.compose-format-advanced { display:none; }
@ -1044,18 +1227,19 @@ body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55);
.mailbox-notes-pane { display:none; }
.mailbox-list-pane { border-right:0; }
.mailbox-shell.detail-visible .mailbox-toolbar,
.mailbox-shell.detail-visible .mailbox-list-bar,
.mailbox-shell.detail-visible .mailbox-filters,
.mailbox-shell.detail-visible .mailbox-list-footer { display:none; }
.mail-detail-reader { --reader-x0:18px; --reader-avatar:34px; --reader-gap:8px; border-radius:10px; }
.mail-detail-toolbar { padding:0 16px; }
.mail-detail-subject-row { flex-direction:column; align-items:flex-start; padding-top:18px; }
.mail-detail-reader { --reader-x0:18px; --reader-avatar:34px; --reader-gap:8px; border-radius:8px; }
.mail-detail-toolbar { padding:0 var(--reader-x0); }
.mail-detail-subject-row { flex-direction:column; align-items:flex-start; padding-top:14px; }
.mail-detail-ai-chips { justify-content:flex-start; }
.mail-detail-message-header { grid-template-columns:34px minmax(0,1fr); }
.mail-detail-message-header { grid-template-columns:var(--reader-avatar) minmax(0,1fr); }
.mail-detail-message-header-actions { grid-column:1/-1; justify-content:flex-start; margin-left:0; }
.mail-detail-message-content,.mail-detail-message-content.is-html { padding-left:var(--reader-x0); }
.mail-detail-reply-bar { padding-left:var(--reader-x0); }
.mail-detail-message-content,.mail-detail-message-content.is-html { padding-left:var(--reader-x2); }
.mail-detail-reply-bar { padding-left:var(--reader-x2); }
.compose-dock { right:12px !important; bottom:12px; }
.compose-window { width:min(calc(100vw - 24px),560px); min-width:0; max-width:none; height:min(560px,calc(100vh - 24px)); }
.compose-window { width:min(calc(100vw - 24px),560px); min-width:0; max-width:none; height:min(572px,calc(100vh - 24px)); }
.compose-window.expanded { width:calc(100vw - 24px); min-width:0; height:min(78vh,calc(100vh - 24px)); }
}
@media(max-width:480px) {
@ -1164,12 +1348,12 @@ body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55);
.agent-spark.pulse { animation:agentSparkPulse 1.2s ease-in-out infinite; }
.agent-label { display:inline-flex; align-items:center; gap:var(--space-1); }
.agent-insight { margin:0 0 var(--space-3); display:grid; gap:var(--space-2); }
.agent-insight-status { margin:0; display:flex; align-items:center; gap:var(--space-2); color:#6d4aff; font-size:var(--text-meta); font-weight:600; line-height:var(--line-meta); }
.agent-insight-block { padding:var(--space-3); border-radius:var(--radius-md); background:#fff; box-shadow:0 1px 2px #08082108; }
.agent-insight-block h4 { margin:0 0 var(--space-1); display:flex; align-items:center; gap:var(--space-1); color:#5a3fe0; font-size:var(--text-meta); font-weight:700; letter-spacing:.2px; text-transform:uppercase; }
.agent-insight-status { margin:0; display:flex; align-items:center; gap:var(--space-2); color:#7a7390; font-size:var(--text-meta); font-weight:550; line-height:var(--line-meta); }
.agent-insight-block { padding:var(--space-3); border-radius:8px; background:#fff; box-shadow:none; border:1px solid rgba(25,22,40,.04); }
.agent-insight-block h4 { margin:0 0 var(--space-1); display:flex; align-items:center; gap:var(--space-1); color:#9a96a4; font-size:10.5px; font-weight:550; letter-spacing:.04em; text-transform:uppercase; }
.agent-insight-block p,.agent-insight-block li { margin:0; color:#4a454f; font-size:var(--text-content); line-height:var(--line-content); }
.agent-insight-block ul { margin:0; padding:0; list-style:none; display:grid; gap:var(--space-1); }
.agent-insight-actions li { display:flex; align-items:center; gap:var(--space-1); color:#6d4aff; font-size:var(--text-meta); font-weight:600; }
.agent-insight-actions li { display:flex; align-items:center; gap:var(--space-1); color:#6d6680; font-size:var(--text-meta); font-weight:550; }
.agent-insight-block.reveal { animation:agentReveal var(--motion-slow) var(--ease-standard) both; }
.agent-insight-block.reveal.delay-1 { animation-delay:80ms; }
.agent-insight-block.reveal.delay-2 { animation-delay:160ms; }
@ -1252,18 +1436,18 @@ body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55);
.mail-label-green { background:#e7f6ee; color:#2f7a52; }
.mail-label-pink { background:#fceeff; color:#8b5ca8; }
.mailbox-rail { width:320px; min-width:320px; max-width:320px; display:flex; flex-direction:column; gap:8px; position:sticky; top:var(--space-3); align-self:start; }
.agent-panel { --ag-coral:#FF6654; --ag-coral-dark:#B72416; --ag-coral-soft:#FFF1ED; --ag-purple:#7357F6; --ag-purple-dark:#5633CE; --ag-purple-soft:#F3F0FF; --ag-blue:#2764F1; --ag-blue-dark:#153EAB; --ag-blue-soft:#EEF3FF; --ag-green:#11966A; --ag-green-dark:#087852; --ag-green-soft:#EDF9F4; --ag-text-primary:#171424; --ag-text-secondary:#77758A; --ag-text-tertiary:#A6A2AE; --ag-border:#E4E1E8; --ag-divider:rgba(25,22,40,.07); --ag-panel:#FFFFFF; width:100%; border:1px solid rgba(26,24,38,0.055); border-radius:14px; background:var(--ag-panel); box-shadow:0 1px 2px rgba(20,18,30,0.025),0 8px 24px rgba(20,18,30,0.035); overflow:hidden; }
.agent-panel-head { padding:14px 14px 0; display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:10px; }
.agent-panel-mark { width:32px; height:32px; flex-shrink:0; display:grid; place-items:center; border-radius:10px; background:#FFF1EC; color:#FF735C; }
.agent-panel { --ag-coral:#FF6654; --ag-coral-dark:#B72416; --ag-coral-soft:#FFF1ED; --ag-purple:#7357F6; --ag-purple-dark:#5633CE; --ag-purple-soft:#F3F0FF; --ag-blue:#2764F1; --ag-blue-dark:#153EAB; --ag-blue-soft:#EEF3FF; --ag-green:#11966A; --ag-green-dark:#087852; --ag-green-soft:#EDF9F4; --ag-text-primary:#171424; --ag-text-secondary:#77758A; --ag-text-tertiary:#A6A2AE; --ag-border:#E4E1E8; --ag-divider:rgba(25,22,40,.06); --ag-panel:#FFFFFF; width:100%; border:1px solid rgba(25,22,40,.045); border-radius:10px; background:var(--ag-panel); box-shadow:none; overflow:hidden; }
.agent-panel-head { padding:12px 12px 0; display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:10px; }
.agent-panel-mark { width:28px; height:28px; flex-shrink:0; display:grid; place-items:center; border-radius:8px; background:#FFF4F1; color:#E56B5C; }
.agent-panel-title { min-width:0; }
.agent-panel-head h2 { margin:0; font:700 15px/18px "DM Sans"; color:#151322; letter-spacing:-.2px; }
.agent-panel-head p { margin:1px 0 0; font:400 10.5px/14px "DM Sans"; color:#77758A; }
.agent-panel-toggle { width:28px; height:28px; display:grid; place-items:center; border:0; border-radius:6px; background:transparent; color:#30304A; cursor:pointer; transition:background 120ms ease; }
.agent-panel-head h2 { margin:0; font:650 14px/18px "DM Sans"; color:#3d3948; letter-spacing:0; }
.agent-panel-head p { margin:1px 0 0; font:400 10.5px/14px "DM Sans"; color:#9a96a4; }
.agent-panel-toggle { width:28px; height:28px; display:grid; place-items:center; border:0; border-radius:6px; background:transparent; color:#6f6b7c; cursor:pointer; transition:background 120ms ease; }
.agent-panel-toggle:hover { background:#F5F4F2; }
.agent-panel-toggle .open { transform:rotate(180deg); }
.agent-panel-body { padding:0 14px 14px; animation:agentReveal var(--motion-slow) var(--ease-standard); }
.agent-panel-hello { margin:14px 0 0; display:grid; gap:2px; }
.agent-panel-hello strong { font:700 14px/18px "DM Sans"; color:#171424; }
.agent-panel-body { padding:0 12px 12px; animation:agentReveal var(--motion-slow) var(--ease-standard); }
.agent-panel-hello { margin:12px 0 0; display:grid; gap:2px; }
.agent-panel-hello strong { font:650 13px/18px "DM Sans"; color:#292536; }
.agent-panel-hello span { font:400 11px/15px "DM Sans"; color:#77758A; }
.agent-panel-note { margin:0 0 8px; font-size:11px; line-height:15px; color:#77758A; }
.agent-caught-up { margin:10px 0 0; padding:10px 12px; border-radius:10px; background:#EDF9F4; border:1px solid #D7F0E5; }
@ -1301,18 +1485,18 @@ body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55);
.agent-stat-empty .agent-stat-label { color:#77757D!important; }
.agent-stat-empty small { color:#B5B2BA!important; }
.agent-quick { margin-top:12px; padding-top:10px; border-top:1px solid var(--ag-divider); }
.agent-quick h3 { margin:0 0 7px; font:600 9.5px/12px "DM Sans"; letter-spacing:.06em; text-transform:uppercase; color:#6F6C7B; }
.agent-quick h3 { margin:0 0 7px; font:550 10px/12px "DM Sans"; letter-spacing:.04em; text-transform:uppercase; color:#9a96a4; }
.agent-quick-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px 8px; }
.agent-quick-btn { height:33px; padding:0 9px; display:flex; align-items:center; justify-content:flex-start; gap:7px; border:1px solid #DFDDE5; border-radius:8px; background:#FFFFFF; color:#292536; font:600 10.5px/12px "DM Sans"; white-space:nowrap; cursor:pointer; transition:background 120ms ease,border-color 120ms ease; }
.agent-quick-btn:hover { background:#F9F7FF; border-color:#D9D0FF; }
.agent-quick-btn { height:32px; padding:0 9px; display:flex; align-items:center; justify-content:flex-start; gap:7px; border:1px solid #E8E6EC; border-radius:7px; background:#FFFFFF; color:#3d3948; font:550 10.5px/12px "DM Sans"; white-space:nowrap; cursor:pointer; transition:background 120ms ease,border-color 120ms ease; }
.agent-quick-btn:hover { background:#FAF9FC; border-color:#E0DCE6; }
.agent-quick-icon { flex-shrink:0; }
.agent-quick-icon-purple { color:#7357F6; }
.agent-quick-icon-purple { color:#8B7AE0; }
.agent-quick-icon-muted { color:#667085; }
.agent-quick-icon-slate { color:#50627A; }
.agent-context-block { margin-top:12px; padding-top:10px; border-top:1px solid var(--ag-divider); }
.agent-context-head { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:6px; }
.agent-context-head h3,.agent-context-block>h3 { margin:0 0 5px; font:700 11.5px/14px "DM Sans"; color:#292536; letter-spacing:0; text-transform:none; }
.agent-context-link { border:0; background:transparent; font:500 9.5px/12px "DM Sans"; color:#5D4CF5; cursor:pointer; }
.agent-context-head h3,.agent-context-block>h3 { margin:0 0 5px; font:550 11px/14px "DM Sans"; color:#6f6b7c; letter-spacing:0; text-transform:none; }
.agent-context-link { border:0; background:transparent; font:500 9.5px/12px "DM Sans"; color:#7a7390; cursor:pointer; }
.agent-context-link:hover { text-decoration:underline; }
.agent-empty-line { margin:0; padding:2px 0; font:400 10px/14px "DM Sans"; color:#A6A2AE; }
.agent-agenda-list { margin:0; padding:0; list-style:none; display:grid; gap:2px; }
@ -1338,14 +1522,14 @@ body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55);
.agent-deadlines ul { margin:0; padding:0; list-style:none; }
.agent-deadlines li { display:flex; align-items:center; justify-content:space-between; padding:4px 0; font-size:10px; }
.agent-deadlines em { color:#77758A; font-size:9px; font-style:normal; }
.agent-ask-input { margin-top:8px; height:35px; padding:0 4px 0 10px; display:flex; align-items:center; gap:2px; border:1px solid #E1DFE7; border-radius:9px; background:#FFFFFF; }
.agent-ask-input:focus-within { border-color:#D9D0FF; box-shadow:0 0 0 2px rgba(115,87,246,0.12); }
.agent-ask-input { margin-top:8px; height:35px; padding:0 4px 0 10px; display:flex; align-items:center; gap:2px; border:1px solid #E8E6EC; border-radius:8px; background:#FFFFFF; }
.agent-ask-input:focus-within { border-color:#E0DCE8; box-shadow:0 0 0 2px rgba(115,87,246,0.06); }
.agent-ask-input input { flex:1; min-width:0; border:0; outline:0; background:transparent; font:400 10px/12px "DM Sans"; color:#171424; }
.agent-ask-input input::placeholder { color:#9995A6; }
.agent-ask-attach { width:26px; height:26px; flex-shrink:0; display:grid; place-items:center; border:0; border-radius:6px; background:transparent; color:#6F6B7C; cursor:pointer; }
.agent-ask-attach:hover { background:#F5F4F2; }
.agent-ask-send { width:28px; height:28px; flex-shrink:0; display:grid; place-items:center; border:0; border-radius:8px; background:#BEC5D4; color:#fff; cursor:pointer; transition:background 120ms ease; }
.agent-ask-send.active { background:#7357F6; }
.agent-ask-send.active { background:#8B7AE0; }
.agent-ask-send:disabled { cursor:default; }
.agent-loading-block,.agent-loading-rows { display:grid; gap:6px; padding:4px 0; }
.agent-loading-pulse { display:block; height:10px; border-radius:999px; background:linear-gradient(90deg,#ece8e4 0%,#f4f1ee 50%,#ece8e4 100%); background-size:200% 100%; animation:agentShimmer 1.2s ease infinite; }

View file

@ -0,0 +1,117 @@
const HAS_TIMEZONE = /(?:Z|[+-]\d{2}:?\d{2})$/i;
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
export function parseApiDate(value) {
if (!value && value !== 0) return null;
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value;
}
if (typeof value === "number") {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
if (DATE_ONLY.test(trimmed)) {
const date = new Date(`${trimmed}T00:00:00`);
return Number.isNaN(date.getTime()) ? null : date;
}
let normalized = trimmed.includes("T") ? trimmed : trimmed.replace(" ", "T");
if (!HAS_TIMEZONE.test(normalized)) {
normalized = `${normalized}Z`;
}
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date;
}
function localFormatter(options) {
return new Intl.DateTimeFormat(undefined, options);
}
export function formatClockTime(value) {
const date = parseApiDate(value);
if (!date) return "";
return localFormatter({ hour: "numeric", minute: "2-digit" }).format(date);
}
export function formatShortDate(value) {
const date = parseApiDate(value);
if (!date) return "";
const now = new Date();
return localFormatter({
month: "short",
day: "numeric",
...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}),
}).format(date);
}
export function formatMailboxTime(value) {
const date = parseApiDate(value);
if (!date) return "";
const now = new Date();
if (date.toDateString() === now.toDateString()) return formatClockTime(date);
return formatShortDate(date);
}
export function formatAbsoluteDateTime(value) {
const date = parseApiDate(value);
if (!date) return "";
return localFormatter({
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
}
export function formatFullDateTime(value) {
const date = parseApiDate(value);
if (!date) return "";
return localFormatter({
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
}
export function formatRelativeTime(value, { compact = false } = {}) {
const date = parseApiDate(value);
if (!date) return "";
const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000));
if (compact) {
if (seconds < 3600) return `${Math.max(1, Math.floor(seconds / 60))}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
const days = Math.floor(seconds / 86400);
if (days < 7) return `${days}d ago`;
return `${Math.floor(days / 7)}w ago`;
}
if (seconds < 60) return "Just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
const days = Math.floor(hours / 24);
if (days === 1) return "Yesterday";
if (days < 7) return `${days} days ago`;
return formatShortDate(date);
}
export function formatMailDetailTime(value) {
const date = parseApiDate(value);
if (!date) return { absolute: "", relative: "" };
const diffMs = Date.now() - date.getTime();
const diffHours = Math.max(1, Math.round(Math.abs(diffMs) / (1000 * 60 * 60)));
const relative = diffHours < 24
? `${diffHours}h ago`
: `${Math.round(diffHours / 24)}d ago`;
return {
absolute: formatAbsoluteDateTime(date),
relative: diffMs >= 0 ? relative : formatClockTime(date),
};
}

View file

@ -1,4 +1,6 @@
const BLOCK_TAGS = new Set(["DIV", "P", "PRE", "LI", "UL", "OL"]);
import { parseApiDate } from "./datetime";
const BLOCK_TAGS = new Set(["DIV", "P", "PRE", "LI", "UL", "OL", "H1", "H2", "H3", "TABLE", "TR"]);
const SAFE_TAGS = new Set([
"A",
"B",
@ -7,16 +9,23 @@ const SAFE_TAGS = new Set([
"CODE",
"DIV",
"EM",
"H1",
"H2",
"H3",
"I",
"LI",
"OL",
"P",
"PRE",
"S",
"SPAN",
"STRIKE",
"STRONG",
"U",
"UL",
]);
const SAFE_STYLES = new Set(["font-weight", "font-style", "text-decoration", "text-align"]);
const UNSAFE_TAGS = new Set(["SCRIPT", "STYLE", "IFRAME", "OBJECT", "EMBED", "LINK", "META", "NOSCRIPT"]);
export function escapeHtml(value = "") {
return value
@ -27,31 +36,117 @@ export function escapeHtml(value = "") {
.replaceAll("'", "&#39;");
}
function cleanMessageHtml(value) {
function parseStyle(styleText) {
const out = {};
for (const part of (styleText || "").split(";")) {
const [rawKey, ...rest] = part.split(":");
if (!rawKey || !rest.length) continue;
const key = rawKey.trim().toLowerCase();
const value = rest.join(":").trim();
if (!SAFE_STYLES.has(key) || !value) continue;
if (/expression|url\s*\(|javascript:/i.test(value)) continue;
out[key] = value;
}
return out;
}
function styleAttribute(styleMap) {
return Object.entries(styleMap)
.map(([key, value]) => `${key}: ${value}`)
.join("; ");
}
function isBold(value) {
const normalized = String(value || "").toLowerCase();
if (normalized === "bold" || normalized === "bolder") return true;
const numeric = Number.parseInt(normalized, 10);
return Number.isFinite(numeric) && numeric >= 600;
}
function allowedHref(value) {
const href = (value || "").trim();
if (!href) return "";
if (/^(https?:|mailto:)/i.test(href)) return href;
if (href.startsWith("/") || href.startsWith("#")) return href;
return "";
}
import { escapeHtml } from "./emailReply";
const wrapper = element.ownerDocument.createElement(tagName);
while (element.firstChild) wrapper.appendChild(element.firstChild);
element.appendChild(wrapper);
}
function applySemanticStyles(element) {
const style = parseStyle(element.getAttribute("style"));
const decoration = String(style["text-decoration"] || "").toLowerCase();
if (isBold(style["font-weight"])) {
wrapContents(element, "strong");
delete style["font-weight"];
}
if (String(style["font-style"] || "").includes("italic")) {
wrapContents(element, "em");
delete style["font-style"];
}
if (decoration.includes("underline")) wrapContents(element, "u");
if (decoration.includes("line-through")) wrapContents(element, "s");
if (decoration.includes("underline") || decoration.includes("line-through")) {
delete style["text-decoration"];
}
if (!["DIV", "P", "H1", "H2", "H3", "LI", "BLOCKQUOTE"].includes(element.tagName)) {
delete style["text-align"];
}
if (Object.keys(style).length) element.setAttribute("style", styleAttribute(style));
else element.removeAttribute("style");
}
export function sanitizeEmailHtml(value) {
if (!value) return "";
const doc = new DOMParser().parseFromString(value, "text/html");
function clean(node) {
[...node.childNodes].forEach((child) => {
if (child.nodeType !== Node.ELEMENT_NODE) return;
if (UNSAFE_TAGS.has(child.tagName)) {
child.remove();
return;
}
if (!SAFE_TAGS.has(child.tagName)) {
if (["SCRIPT", "STYLE", "IFRAME", "OBJECT", "EMBED"].includes(child.tagName)) {
child.remove();
return;
}
clean(child);
child.replaceWith(...child.childNodes);
return;
}
const href = child.tagName === "A" ? allowedHref(child.getAttribute("href")) : "";
const className = child.getAttribute("class") || "";
const styleText = child.getAttribute("style") || "";
const keepQuoteClass = /\b(gmail_quote|gmail_attr)\b/.test(className);
[...child.attributes].forEach((attribute) => child.removeAttribute(attribute.name));
if (href) child.setAttribute("href", href);
if (keepQuoteClass) {
child.setAttribute("class", className.split(/\s+/).filter((name) => (
name === "gmail_quote" || name === "gmail_attr"
)).join(" "));
}
if (child.tagName === "BLOCKQUOTE") {
child.setAttribute(
"style",
"margin:0 0 0 .8ex;border-left:1px solid #ccc;padding-left:1ex",
);
} else if (styleText) {
child.setAttribute("style", styleText);
applySemanticStyles(child);
}
clean(child);
if (
child.tagName === "SPAN"
&& !child.attributes.length
&& child.parentNode
) {
child.replaceWith(...child.childNodes);
}
});
}
@ -78,8 +173,8 @@ function attributionDate(message) {
|| message?.sent_at
|| message?.created_at
|| message?.updated_at;
const date = value ? new Date(value) : null;
if (!date || Number.isNaN(date.getTime())) return "";
const date = parseApiDate(value);
if (!date) return "";
const datePart = new Intl.DateTimeFormat(undefined, {
weekday: "short",
@ -95,7 +190,7 @@ function attributionDate(message) {
}
export function buildReplyBodyHtml(message) {
const original = cleanMessageHtml(message?.body_html)
const original = sanitizeEmailHtml(message?.body_html)
|| textBodyAsHtml(message?.body_text || message?.snippet || "");
const date = attributionDate(message);
const identity = senderIdentity(message);
@ -142,3 +237,16 @@ export function plainTextFromEmailHtml(value) {
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export function htmlForOutboundEmail(value) {
const cleaned = sanitizeEmailHtml(value);
if (!cleaned || !plainTextFromEmailHtml(cleaned)) return "";
return `<div dir="ltr">${cleaned}</div>`;
}
export function visibleMessageHtml(value) {
if (!value) return "";
const doc = new DOMParser().parseFromString(value, "text/html");
doc.querySelectorAll(".gmail_quote").forEach((node) => node.remove());
return sanitizeEmailHtml(doc.body.innerHTML);
}

View file

@ -1,4 +1,5 @@
import { senderDisplayName } from "./senderAvatar";
import { parseApiDate } from "./datetime";
const CATEGORY_ORDER = ["Primary", "Updates", "Promotions", "Social", "Finance"];
@ -135,9 +136,7 @@ export function attachmentCount(email) {
function emailTimestamp(email) {
const value = email.received_at || email.sent_at || email.created_at || email.updated_at;
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
return parseApiDate(value);
}
function formatRelativeAge(date) {