diff --git a/backend/app/database.py b/backend/app/database.py index c06dbef..3fe78df 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -1,3 +1,5 @@ +from datetime import timezone + from collections.abc import AsyncIterator from pymongo import AsyncMongoClient @@ -14,7 +16,11 @@ settings = get_settings() engine = create_async_engine(settings.database_url, pool_pre_ping=True) SessionFactory = async_sessionmaker(engine, expire_on_commit=False) -mongo_client: AsyncMongoClient = AsyncMongoClient(settings.mongo_url) +mongo_client: AsyncMongoClient = AsyncMongoClient( + settings.mongo_url, + tz_aware=True, + tzinfo=timezone.utc, +) mongo_db = mongo_client[settings.mongo_database] diff --git a/backend/app/routers/agent_handoff.py b/backend/app/routers/agent_handoff.py index b071b2d..2e0518f 100644 --- a/backend/app/routers/agent_handoff.py +++ b/backend/app/routers/agent_handoff.py @@ -72,10 +72,10 @@ async def list_handoff_rules( def _handoff_status_label(value: str) -> str: return { - "ACTIVE": "Active", + "ACTIVE": "Open", "WAITING_FOR_HUMAN": "Waiting for human", "HUMAN_REPLIED": "Human replied", - "CLOSED": "Closed", + "CLOSED": "Resolved", }.get(value, value.replace("_", " ").title()) @@ -125,6 +125,7 @@ async def list_handoff_conversations( external_subject=handoff.get("external_subject", ""), external_sender=handoff.get("external_sender", ""), human_email=handoff.get("human_email", ""), + human_name=handoff.get("human_name", ""), status=handoff.get("status", "ACTIVE"), status_label=_handoff_status_label( handoff.get("status", "ACTIVE") diff --git a/backend/app/routers/emails.py b/backend/app/routers/emails.py index bae1ba8..677960b 100644 --- a/backend/app/routers/emails.py +++ b/backend/app/routers/emails.py @@ -21,6 +21,8 @@ from app.schemas import ( MessageReadState, MessageStarState, MessageSummary, + parse_api_datetime, + utc_iso, utc_now, ) from app.services.domain_provisioning import get_domain_by_name @@ -72,7 +74,7 @@ def address(value: str, name: str | None = None) -> dict[str, str]: def encode_cursor(entry: dict) -> str: value = json.dumps( - {"created_at": entry["created_at"].isoformat(), "id": str(entry["_id"])}, + {"created_at": utc_iso(entry["created_at"]), "id": str(entry["_id"])}, separators=(",", ":"), ) return base64.urlsafe_b64encode(value.encode()).decode().rstrip("=") @@ -82,7 +84,7 @@ def decode_cursor(value: str) -> tuple[datetime, ObjectId]: try: padded = value + "=" * (-len(value) % 4) payload = json.loads(base64.urlsafe_b64decode(padded).decode()) - return datetime.fromisoformat(payload["created_at"]), ObjectId(payload["id"]) + return parse_api_datetime(payload["created_at"]), ObjectId(payload["id"]) except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, diff --git a/backend/app/routers/threads.py b/backend/app/routers/threads.py index e5f5bf1..66a2d5b 100644 --- a/backend/app/routers/threads.py +++ b/backend/app/routers/threads.py @@ -11,7 +11,7 @@ from app.database import get_mongo_database from app.models import Account from app.routers.auth import get_current_account from app.routers.emails import decode_cursor, encode_cursor, summarize -from app.schemas import MessageRead, ThreadPage, ThreadRead, ThreadSummary +from app.schemas import MessageRead, ThreadPage, ThreadRead, ThreadSummary, parse_api_datetime, utc_iso from app.services.mailbox_scope import apply_external_scope from app.services.mime_parser import parse_mime from app.services.reply_extractor import reply_fields_for_message @@ -23,7 +23,7 @@ router = APIRouter(prefix="/threads", tags=["threads"]) def encode_thread_cursor(thread: dict) -> str: value = json.dumps( { - "latest_at": thread["latest_at"].isoformat(), + "latest_at": utc_iso(thread["latest_at"]), "id": str(thread["_id"]), }, separators=(",", ":"), @@ -35,7 +35,7 @@ def decode_thread_cursor(value: str) -> tuple[datetime, ObjectId]: try: padded = value + "=" * (-len(value) % 4) payload = json.loads(base64.urlsafe_b64decode(padded).decode()) - return datetime.fromisoformat(payload["latest_at"]), ObjectId(payload["id"]) + return parse_api_datetime(payload["latest_at"]), ObjectId(payload["id"]) except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -188,9 +188,10 @@ async def list_threads( } ) pipeline.append({"$sort": {"latest_at": -1, "_id": -1}}) + page_stages: list[dict] = [] if cursor: latest_at, thread_id = decode_thread_cursor(cursor) - pipeline.append( + page_stages.append( { "$match": { "$or": [ @@ -200,8 +201,8 @@ async def list_threads( } } ) - pipeline.append({"$limit": limit + 1}) - pipeline.append( + page_stages.append({"$limit": limit + 1}) + page_stages.append( { "$addFields": { "latest_entry": { @@ -215,8 +216,19 @@ async def list_threads( } } ) - cursor = await database.mailbox_entries.aggregate(pipeline) - rows = [row async for row in cursor] + pipeline.append( + { + "$facet": { + "total": [{"$count": "count"}], + "page": page_stages, + } + } + ) + aggregate = await database.mailbox_entries.aggregate(pipeline) + facets = [row async for row in aggregate] + facet = facets[0] if facets else {"total": [], "page": []} + total = int((facet.get("total") or [{}])[0].get("count") or 0) + rows = list(facet.get("page") or []) has_more = len(rows) > limit rows = rows[:limit] items = [thread_summary_from_group(row, mailbox) for row in rows] @@ -225,7 +237,7 @@ async def list_threads( next_cursor = encode_thread_cursor( {"latest_at": rows[-1]["latest_at"], "_id": rows[-1]["_id"]} ) - return ThreadPage(items=items, next_cursor=next_cursor) + return ThreadPage(items=items, next_cursor=next_cursor, total=total) async def message_read( diff --git a/backend/app/schemas.py b/backend/app/schemas.py index dd4cb5d..abcc9ae 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,5 +1,4 @@ -from datetime import datetime, timezone -from datetime import date +from datetime import date, datetime, timezone from typing import Literal from uuid import UUID @@ -313,6 +312,7 @@ class HandoffConversationSummary(BaseModel): external_subject: str external_sender: str human_email: str + human_name: str = "" status: str status_label: str current_owner: str @@ -488,6 +488,7 @@ class ThreadSummary(BaseModel): class ThreadPage(BaseModel): items: list[ThreadSummary] next_cursor: str | None = None + total: int = 0 class ThreadRead(BaseModel): @@ -591,3 +592,18 @@ class DnsSetupResponse(BaseModel): def utc_now() -> datetime: return datetime.now(timezone.utc) + + +def as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def utc_iso(value: datetime) -> str: + return as_utc(value).isoformat().replace("+00:00", "Z") + + +def parse_api_datetime(value: str) -> datetime: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + return as_utc(parsed) diff --git a/backend/tests/test_email_helpers.py b/backend/tests/test_email_helpers.py index ccde8db..5d9b080 100644 --- a/backend/tests/test_email_helpers.py +++ b/backend/tests/test_email_helpers.py @@ -17,6 +17,15 @@ def test_cursor_round_trip_preserves_sort_key(): assert entry_id == entry["_id"] +def test_naive_cursor_timestamp_is_treated_as_utc(): + naive = datetime(2026, 8, 9, 12, 30) + created_at, _entry_id = decode_cursor( + encode_cursor({"_id": ObjectId(), "created_at": naive}) + ) + assert created_at.tzinfo is not None + assert created_at == datetime(2026, 8, 9, 12, 30, tzinfo=timezone.utc) + + def test_summary_uses_account_mailbox_read_state(): now = datetime(2026, 8, 9, 12, 30, tzinfo=timezone.utc) message_id = ObjectId() diff --git a/frontend/src/components/CompanyDomains.jsx b/frontend/src/components/CompanyDomains.jsx index 308a52c..49902c5 100644 --- a/frontend/src/components/CompanyDomains.jsx +++ b/frontend/src/components/CompanyDomains.jsx @@ -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({
Status
{domain.status === "active" ? "Active" : "Pending DNS"}
DKIM selector
{domain.dkim_selector}
-
Added
{new Date(domain.created_at).toLocaleDateString()}
+
Added
{formatShortDate(domain.created_at)}
)} diff --git a/frontend/src/components/CompanyGroups.jsx b/frontend/src/components/CompanyGroups.jsx index 4d58150..0ae45ae 100644 --- a/frontend/src/components/CompanyGroups.jsx +++ b/frontend/src/components/CompanyGroups.jsx @@ -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} - {formatRelativeTime(group.created_at)} + {formatRelativeTime(group.created_at, { compact: true })} + ); +} + 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 === "
" ? "" : html); + setBodyHtml(html === "
" || html === "

" ? "" : 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, + `${escapeHtml(label)}`, + ); + } + 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 ? "
" : ""}${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 === "
" || rawHtml === "

" ? "" : 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({ )} + {linkDialog && ( +
+ + +
+ )} +
@@ -629,29 +819,29 @@ export default function ComposeModal({
- - - - + execFormat("bold")}> + execFormat("italic")}> + execFormat("underline")}> + execFormat("strikeThrough")}>
- - - + execFormat("justifyLeft")}> + execFormat("justifyCenter")}> + execFormat("justifyRight")}>
- - + execFormat("insertUnorderedList")}> + execFormat("insertOrderedList")}>
- - - - - + execFormat("indent")}> + execFormat("outdent")}> + execFormat("formatBlock", "blockquote")}> + + execFormat("removeFormat")}>
@@ -680,11 +870,23 @@ export default function ComposeModal({ -
- {showEmoji && ( diff --git a/frontend/src/components/HandoffConversation.jsx b/frontend/src/components/HandoffConversation.jsx index 003acf3..6e4f4e8 100644 --- a/frontend/src/components/HandoffConversation.jsx +++ b/frontend/src/components/HandoffConversation.jsx @@ -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"} - + {message.role === "human" && message.relay_status === "SENT" && ( diff --git a/frontend/src/components/HandoffInbox.jsx b/frontend/src/components/HandoffInbox.jsx index 3255019..e9d4809 100644 --- a/frontend/src/components/HandoffInbox.jsx +++ b/frontend/src/components/HandoffInbox.jsx @@ -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 (
-

- {mailbox === "handoff_inbox" - ? "Replies received from Human Owners" - : "Messages mirrored from Gentic to Human Owners"} -

+
+

{isInbox ? "Handoff Inbox" : "Handoff Outbox"}

+

+ {isInbox + ? "Replies received from Human Owners." + : "Messages mirrored from Gentic to Human Owners."} +

+
{error &&

{error}

} {loading ? (

Loading handoffs…

@@ -55,7 +60,7 @@ export default function HandoffInbox({ mailbox = "handoff_inbox", onSelect }) { -

{mailbox === "handoff_inbox" ? "No Human replies yet" : "No mirrored messages yet"}

+

{isInbox ? "No Human replies yet" : "No mirrored messages yet"}

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 }) {

) : (
- {items.map((item) => ( - - ))} + {items.map((item) => { + const preview = previewSnippet(item.snippet); + return ( + + ); + })}
)}
diff --git a/frontend/src/components/HumanHandoff.jsx b/frontend/src/components/HumanHandoff.jsx index 34cd401..4ae9f7c 100644 --- a/frontend/src/components/HumanHandoff.jsx +++ b/frontend/src/components/HumanHandoff.jsx @@ -16,8 +16,10 @@ export default function HumanHandoff({ onOpenExternal }) {
-

Human Handoff

-

Private mirrored conversations between your Agent and Human Owners.

+

Human Handoff

+

+ Manage conversations that your Agent has handed over to a Human Owner. +

- {!status.loading && ( - - )} - {tag} ))}
- + ); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 49df6cd..b3920be 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } diff --git a/frontend/src/utils/datetime.js b/frontend/src/utils/datetime.js new file mode 100644 index 0000000..aa0bea2 --- /dev/null +++ b/frontend/src/utils/datetime.js @@ -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), + }; +} diff --git a/frontend/src/utils/emailReply.js b/frontend/src/utils/emailReply.js index 54ce2aa..315b9d2 100644 --- a/frontend/src/utils/emailReply.js +++ b/frontend/src/utils/emailReply.js @@ -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("'", "'"); } -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 `
${cleaned}
`; +} + +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); +} diff --git a/frontend/src/utils/inboxSignals.js b/frontend/src/utils/inboxSignals.js index d9afc3e..b84f24f 100644 --- a/frontend/src/utils/inboxSignals.js +++ b/frontend/src/utils/inboxSignals.js @@ -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) {