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

@ -1,3 +1,5 @@
from datetime import timezone
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from pymongo import AsyncMongoClient from pymongo import AsyncMongoClient
@ -14,7 +16,11 @@ settings = get_settings()
engine = create_async_engine(settings.database_url, pool_pre_ping=True) engine = create_async_engine(settings.database_url, pool_pre_ping=True)
SessionFactory = async_sessionmaker(engine, expire_on_commit=False) 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] mongo_db = mongo_client[settings.mongo_database]

View file

@ -72,10 +72,10 @@ async def list_handoff_rules(
def _handoff_status_label(value: str) -> str: def _handoff_status_label(value: str) -> str:
return { return {
"ACTIVE": "Active", "ACTIVE": "Open",
"WAITING_FOR_HUMAN": "Waiting for human", "WAITING_FOR_HUMAN": "Waiting for human",
"HUMAN_REPLIED": "Human replied", "HUMAN_REPLIED": "Human replied",
"CLOSED": "Closed", "CLOSED": "Resolved",
}.get(value, value.replace("_", " ").title()) }.get(value, value.replace("_", " ").title())
@ -125,6 +125,7 @@ async def list_handoff_conversations(
external_subject=handoff.get("external_subject", ""), external_subject=handoff.get("external_subject", ""),
external_sender=handoff.get("external_sender", ""), external_sender=handoff.get("external_sender", ""),
human_email=handoff.get("human_email", ""), human_email=handoff.get("human_email", ""),
human_name=handoff.get("human_name", ""),
status=handoff.get("status", "ACTIVE"), status=handoff.get("status", "ACTIVE"),
status_label=_handoff_status_label( status_label=_handoff_status_label(
handoff.get("status", "ACTIVE") handoff.get("status", "ACTIVE")

View file

@ -21,6 +21,8 @@ from app.schemas import (
MessageReadState, MessageReadState,
MessageStarState, MessageStarState,
MessageSummary, MessageSummary,
parse_api_datetime,
utc_iso,
utc_now, utc_now,
) )
from app.services.domain_provisioning import get_domain_by_name 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: def encode_cursor(entry: dict) -> str:
value = json.dumps( 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=(",", ":"), separators=(",", ":"),
) )
return base64.urlsafe_b64encode(value.encode()).decode().rstrip("=") return base64.urlsafe_b64encode(value.encode()).decode().rstrip("=")
@ -82,7 +84,7 @@ def decode_cursor(value: str) -> tuple[datetime, ObjectId]:
try: try:
padded = value + "=" * (-len(value) % 4) padded = value + "=" * (-len(value) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded).decode()) 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: except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,

View file

@ -11,7 +11,7 @@ from app.database import get_mongo_database
from app.models import Account from app.models import Account
from app.routers.auth import get_current_account from app.routers.auth import get_current_account
from app.routers.emails import decode_cursor, encode_cursor, summarize 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.mailbox_scope import apply_external_scope
from app.services.mime_parser import parse_mime from app.services.mime_parser import parse_mime
from app.services.reply_extractor import reply_fields_for_message 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: def encode_thread_cursor(thread: dict) -> str:
value = json.dumps( value = json.dumps(
{ {
"latest_at": thread["latest_at"].isoformat(), "latest_at": utc_iso(thread["latest_at"]),
"id": str(thread["_id"]), "id": str(thread["_id"]),
}, },
separators=(",", ":"), separators=(",", ":"),
@ -35,7 +35,7 @@ def decode_thread_cursor(value: str) -> tuple[datetime, ObjectId]:
try: try:
padded = value + "=" * (-len(value) % 4) padded = value + "=" * (-len(value) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded).decode()) 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: except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@ -188,9 +188,10 @@ async def list_threads(
} }
) )
pipeline.append({"$sort": {"latest_at": -1, "_id": -1}}) pipeline.append({"$sort": {"latest_at": -1, "_id": -1}})
page_stages: list[dict] = []
if cursor: if cursor:
latest_at, thread_id = decode_thread_cursor(cursor) latest_at, thread_id = decode_thread_cursor(cursor)
pipeline.append( page_stages.append(
{ {
"$match": { "$match": {
"$or": [ "$or": [
@ -200,8 +201,8 @@ async def list_threads(
} }
} }
) )
pipeline.append({"$limit": limit + 1}) page_stages.append({"$limit": limit + 1})
pipeline.append( page_stages.append(
{ {
"$addFields": { "$addFields": {
"latest_entry": { "latest_entry": {
@ -215,8 +216,19 @@ async def list_threads(
} }
} }
) )
cursor = await database.mailbox_entries.aggregate(pipeline) pipeline.append(
rows = [row async for row in cursor] {
"$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 has_more = len(rows) > limit
rows = rows[:limit] rows = rows[:limit]
items = [thread_summary_from_group(row, mailbox) for row in rows] items = [thread_summary_from_group(row, mailbox) for row in rows]
@ -225,7 +237,7 @@ async def list_threads(
next_cursor = encode_thread_cursor( next_cursor = encode_thread_cursor(
{"latest_at": rows[-1]["latest_at"], "_id": rows[-1]["_id"]} {"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( async def message_read(

View file

@ -1,5 +1,4 @@
from datetime import datetime, timezone from datetime import date, datetime, timezone
from datetime import date
from typing import Literal from typing import Literal
from uuid import UUID from uuid import UUID
@ -313,6 +312,7 @@ class HandoffConversationSummary(BaseModel):
external_subject: str external_subject: str
external_sender: str external_sender: str
human_email: str human_email: str
human_name: str = ""
status: str status: str
status_label: str status_label: str
current_owner: str current_owner: str
@ -488,6 +488,7 @@ class ThreadSummary(BaseModel):
class ThreadPage(BaseModel): class ThreadPage(BaseModel):
items: list[ThreadSummary] items: list[ThreadSummary]
next_cursor: str | None = None next_cursor: str | None = None
total: int = 0
class ThreadRead(BaseModel): class ThreadRead(BaseModel):
@ -591,3 +592,18 @@ class DnsSetupResponse(BaseModel):
def utc_now() -> datetime: def utc_now() -> datetime:
return datetime.now(timezone.utc) 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)

View file

@ -17,6 +17,15 @@ def test_cursor_round_trip_preserves_sort_key():
assert entry_id == entry["_id"] 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(): def test_summary_uses_account_mailbox_read_state():
now = datetime(2026, 8, 9, 12, 30, tzinfo=timezone.utc) now = datetime(2026, 8, 9, 12, 30, tzinfo=timezone.utc)
message_id = ObjectId() message_id = ObjectId()

View file

@ -20,6 +20,7 @@ import {
listMyDomains, listMyDomains,
verifyMyDomainDns, verifyMyDomainDns,
} from "../api"; } from "../api";
import { formatRelativeTime, formatShortDate } from "../utils/datetime";
const ADD_STEPS = [ const ADD_STEPS = [
{ id: 1, title: "Add domain" }, { 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) { async function copyText(value) {
try { try {
await navigator.clipboard.writeText(value); await navigator.clipboard.writeText(value);
@ -419,7 +408,7 @@ function DomainDetail({
<dl className="company-domain-meta"> <dl className="company-domain-meta">
<div><dt>Status</dt><dd>{domain.status === "active" ? "Active" : "Pending DNS"}</dd></div> <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>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> </dl>
</div> </div>
)} )}

View file

@ -15,6 +15,7 @@ import {
listMailGroups, listMailGroups,
listOrgMembers, listOrgMembers,
} from "../api"; } from "../api";
import { formatRelativeTime } from "../utils/datetime";
const TYPE_TABS = [ const TYPE_TABS = [
{ key: "all", label: "All groups" }, { 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])?$/; 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) { function groupInitial(name) {
return (name?.trim()?.[0] || "G").toUpperCase(); return (name?.trim()?.[0] || "G").toUpperCase();
} }
@ -594,7 +584,7 @@ export default function CompanyGroups({ account }) {
{group.member_count} {group.member_count}
</span> </span>
</td> </td>
<td>{formatRelativeTime(group.created_at)}</td> <td>{formatRelativeTime(group.created_at, { compact: true })}</td>
<td> <td>
<button type="button" className="people-row-menu" aria-label="More actions"> <button type="button" className="people-row-menu" aria-label="More actions">
<MoreHorizontal size={16} /> <MoreHorizontal size={16} />

View file

@ -20,6 +20,7 @@ import {
verifyMyDomainDns, verifyMyDomainDns,
} from "../api"; } from "../api";
import { getOrgDetails } from "./CompanyOverviewSetup"; import { getOrgDetails } from "./CompanyOverviewSetup";
import { formatRelativeTime, parseApiDate } from "../utils/datetime";
const HEALTH_ITEMS = [ const HEALTH_ITEMS = [
{ purpose: "ownership", label: "Domain verified" }, { purpose: "ownership", label: "Domain verified" },
@ -29,16 +30,6 @@ const HEALTH_ITEMS = [
{ purpose: "dmarc", label: "DMARC" }, { 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) { function companyTitle(account, details, domainName) {
if (details?.companyName?.trim()) return details.companyName.trim(); if (details?.companyName?.trim()) return details.companyName.trim();
if (domainName) { if (domainName) {
@ -147,7 +138,7 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
})), })),
]; ];
return events 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); .slice(0, 5);
}, [groups.items, members.items]); }, [groups.items, members.items]);
@ -243,7 +234,7 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
{dnsError {dnsError
? dnsError ? dnsError
: lastChecked : lastChecked
? `Last checked ${formatRelativeTime(lastChecked)}` ? `Last checked ${formatRelativeTime(lastChecked, { compact: true })}`
: "Not checked yet"} : "Not checked yet"}
</span> </span>
<button type="button" onClick={loadDnsHealth} disabled={dnsLoading || !primaryDomain}> <button type="button" onClick={loadDnsHealth} disabled={dnsLoading || !primaryDomain}>
@ -274,7 +265,7 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
</span> </span>
<div> <div>
<p>{item.text}</p> <p>{item.text}</p>
<time>{formatRelativeTime(item.time)}</time> <time>{formatRelativeTime(item.time, { compact: true })}</time>
</div> </div>
</li> </li>
); );

View file

@ -28,13 +28,14 @@ import {
RemoveFormatting, RemoveFormatting,
IndentIncrease, IndentIncrease,
IndentDecrease, IndentDecrease,
Type,
} from "lucide-react"; } from "lucide-react";
import AgentSpark from "./AgentSpark"; import AgentSpark from "./AgentSpark";
import RecipientField, { recipientsToPayload } from "./compose/RecipientField"; import RecipientField, { recipientsToPayload } from "./compose/RecipientField";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { listMailAliases, listOrgMembers, sendEmail } from "../api"; import { listMailAliases, listOrgMembers, sendEmail } from "../api";
import { clearDraft, draftStorageKey } from "../utils/composeDraft"; import { clearDraft, draftStorageKey } from "../utils/composeDraft";
import { plainTextFromEmailHtml } from "../utils/emailReply"; import { escapeHtml, htmlForOutboundEmail, plainTextFromEmailHtml, sanitizeEmailHtml } from "../utils/emailReply";
const EMOJI_SAMPLES = ["😀", "😊", "👍", "🙏", "✅", "🎉", "📎", "📅", "💡", "🔥", "❤️", "😅"]; 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 }) { function SendSplitButton({ onSend, onSchedule, disabled, iconStroke = 1.7 }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef(null); const ref = useRef(null);
@ -179,6 +211,8 @@ export default function ComposeModal({
const imageInputRef = useRef(null); const imageInputRef = useRef(null);
const draftTimerRef = useRef(null); const draftTimerRef = useRef(null);
const draftHydratedRef = useRef(false); const draftHydratedRef = useRef(false);
const savedRangeRef = useRef(null);
const linkPopoverRef = useRef(null);
const [showCc, setShowCc] = useState(false); const [showCc, setShowCc] = useState(false);
const [showBcc, setShowBcc] = useState(false); const [showBcc, setShowBcc] = useState(false);
@ -195,6 +229,8 @@ export default function ComposeModal({
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const [warnings, setWarnings] = useState([]); const [warnings, setWarnings] = useState([]);
const [showEmoji, setShowEmoji] = useState(false); const [showEmoji, setShowEmoji] = useState(false);
const [linkDialog, setLinkDialog] = useState(null);
const [formatState, setFormatState] = useState({});
const [showQuoted, setShowQuoted] = useState(false); const [showQuoted, setShowQuoted] = useState(false);
const [status, setStatus] = useState({ sending: false, error: "" }); const [status, setStatus] = useState({ sending: false, error: "" });
const [replyToMessageId, setReplyToMessageId] = useState(null); const [replyToMessageId, setReplyToMessageId] = useState(null);
@ -303,6 +339,26 @@ export default function ComposeModal({
setWarnings(detectWarnings({ bodyHtml, to, cc, bcc, attachments, orgDomain })); setWarnings(detectWarnings({ bodyHtml, to, cc, bcc, attachments, orgDomain }));
}, [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) { function flashNotice(message) {
setNotice(message); setNotice(message);
window.setTimeout(() => setNotice(""), 2800); window.setTimeout(() => setNotice(""), 2800);
@ -310,24 +366,95 @@ export default function ComposeModal({
function syncBodyFromEditor() { function syncBodyFromEditor() {
const html = editorRef.current?.innerHTML || ""; 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) { function execFormat(command, value = null) {
editorRef.current?.focus(); restoreSelection();
document.execCommand("defaultParagraphSeparator", false, "div");
document.execCommand(command, false, value); document.execCommand(command, false, value);
saveSelection();
syncBodyFromEditor(); syncBodyFromEditor();
updateFormatState();
} }
function insertLink() { function openLinkDialog() {
const url = window.prompt("Enter link URL", "https://"); 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; 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) { function insertEmoji(emoji) {
editorRef.current?.focus(); restoreSelection();
document.execCommand("insertText", false, emoji); document.execCommand("insertText", false, emoji);
saveSelection();
syncBodyFromEditor(); syncBodyFromEditor();
setShowEmoji(false); setShowEmoji(false);
} }
@ -336,9 +463,10 @@ export default function ComposeModal({
if (!signature.html) return; if (!signature.html) return;
const editor = editorRef.current; const editor = editorRef.current;
if (!editor) return; if (!editor) return;
editor.focus(); restoreSelection();
const block = `${editor.innerHTML ? "<br>" : ""}${signature.html}`; const block = `${editor.innerHTML ? "<br>" : ""}${signature.html}`;
document.execCommand("insertHTML", false, block); document.execCommand("insertHTML", false, block);
saveSelection();
syncBodyFromEditor(); syncBodyFromEditor();
flashNotice(`Inserted signature: ${signature.label}`); flashNotice(`Inserted signature: ${signature.label}`);
} }
@ -386,11 +514,25 @@ export default function ComposeModal({
onDiscard?.(); 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 () => { const submitSend = useCallback(async () => {
if (!to.length) { if (!to.length) {
setStatus({ sending: false, error: "Add at least one recipient." }); setStatus({ sending: false, error: "Add at least one recipient." });
return; 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: "" }); setStatus({ sending: true, error: "" });
try { try {
const payload = { const payload = {
@ -398,8 +540,8 @@ export default function ComposeModal({
cc: recipientsToPayload(cc), cc: recipientsToPayload(cc),
bcc: recipientsToPayload(bcc), bcc: recipientsToPayload(bcc),
subject, subject,
body_text: plainTextFromHtml(bodyHtml), body_text: outboundText,
body_html: bodyHtml || null, body_html: outboundHtml || null,
}; };
if (replyToMessageId) { if (replyToMessageId) {
payload.reply_to_message_id = replyToMessageId; payload.reply_to_message_id = replyToMessageId;
@ -419,12 +561,22 @@ export default function ComposeModal({
const mod = event.metaKey || event.ctrlKey; const mod = event.metaKey || event.ctrlKey;
if (!isFocused) return; if (!isFocused) return;
if (mod && event.key === "Enter" && !status.sending) { if (mod && event.key === "Enter" && !status.sending) {
if (linkDialog) {
event.preventDefault();
applyLink(event);
return;
}
event.preventDefault(); event.preventDefault();
submitSend(); submitSend();
} }
if (event.key === "Escape" && linkDialog) {
event.preventDefault();
setLinkDialog(null);
return;
}
if (mod && event.key === "k") { if (mod && event.key === "k") {
event.preventDefault(); event.preventDefault();
insertLink(); openLinkDialog();
} }
if (mod && event.shiftKey && event.key.toLowerCase() === "c") { if (mod && event.shiftKey && event.key.toLowerCase() === "c") {
event.preventDefault(); event.preventDefault();
@ -437,7 +589,7 @@ export default function ComposeModal({
} }
document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown);
}, [status.sending, submitSend, isFocused]); }, [status.sending, submitSend, isFocused, linkDialog]);
function popOut() { function popOut() {
const url = `${window.location.origin}${window.location.pathname}?popup=compose&compose=${windowId}`; const url = `${window.location.origin}${window.location.pathname}?popup=compose&compose=${windowId}`;
@ -595,7 +747,14 @@ export default function ComposeModal({
role="textbox" role="textbox"
aria-multiline="true" aria-multiline="true"
data-placeholder="Write your message…" 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()} onDragOver={(event) => event.preventDefault()}
onDrop={(event) => { onDrop={(event) => {
event.preventDefault(); event.preventDefault();
@ -614,6 +773,37 @@ export default function ComposeModal({
</button> </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-bottom-stack">
<div className="compose-agent-bar compose-agent-bar-disabled" title="AG Agent drafting — coming soon"> <div className="compose-agent-bar compose-agent-bar-disabled" title="AG Agent drafting — coming soon">
<AgentSpark /> <AgentSpark />
@ -629,29 +819,29 @@ export default function ComposeModal({
<div className="compose-format-bar" aria-label="Formatting"> <div className="compose-format-bar" aria-label="Formatting">
<div className="compose-format-group"> <div className="compose-format-group">
<button type="button" title="Bold" onClick={() => execFormat("bold")}><Bold size={14} strokeWidth={iconStroke} /></button> <FormatButton title="Bold" active={formatState.bold} onClick={() => execFormat("bold")}><Bold size={14} strokeWidth={iconStroke} /></FormatButton>
<button type="button" title="Italic" onClick={() => execFormat("italic")}><Italic size={14} strokeWidth={iconStroke} /></button> <FormatButton title="Italic" active={formatState.italic} onClick={() => execFormat("italic")}><Italic size={14} strokeWidth={iconStroke} /></FormatButton>
<button type="button" title="Underline" onClick={() => execFormat("underline")}><Underline size={14} strokeWidth={iconStroke} /></button> <FormatButton title="Underline" active={formatState.underline} onClick={() => execFormat("underline")}><Underline size={14} strokeWidth={iconStroke} /></FormatButton>
<button type="button" title="Strikethrough" onClick={() => execFormat("strikeThrough")}><Strikethrough size={14} strokeWidth={iconStroke} /></button> <FormatButton title="Strikethrough" active={formatState.strikeThrough} onClick={() => execFormat("strikeThrough")}><Strikethrough size={14} strokeWidth={iconStroke} /></FormatButton>
</div> </div>
<span className="compose-format-divider" /> <span className="compose-format-divider" />
<div className="compose-format-group compose-format-align"> <div className="compose-format-group compose-format-align">
<button type="button" title="Align left" onClick={() => execFormat("justifyLeft")}><AlignLeft size={14} strokeWidth={iconStroke} /></button> <FormatButton title="Align left" active={formatState.justifyLeft} onClick={() => execFormat("justifyLeft")}><AlignLeft size={14} strokeWidth={iconStroke} /></FormatButton>
<button type="button" title="Align center" onClick={() => execFormat("justifyCenter")}><AlignCenter size={14} strokeWidth={iconStroke} /></button> <FormatButton title="Align center" active={formatState.justifyCenter} onClick={() => execFormat("justifyCenter")}><AlignCenter size={14} strokeWidth={iconStroke} /></FormatButton>
<button type="button" title="Align right" onClick={() => execFormat("justifyRight")}><AlignRight size={14} strokeWidth={iconStroke} /></button> <FormatButton title="Align right" active={formatState.justifyRight} onClick={() => execFormat("justifyRight")}><AlignRight size={14} strokeWidth={iconStroke} /></FormatButton>
</div> </div>
<span className="compose-format-divider" /> <span className="compose-format-divider" />
<div className="compose-format-group"> <div className="compose-format-group">
<button type="button" title="Bulleted list" onClick={() => execFormat("insertUnorderedList")}><List size={14} strokeWidth={iconStroke} /></button> <FormatButton title="Bulleted list" active={formatState.insertUnorderedList} onClick={() => execFormat("insertUnorderedList")}><List size={14} strokeWidth={iconStroke} /></FormatButton>
<button type="button" title="Numbered list" onClick={() => execFormat("insertOrderedList")}><ListOrdered size={14} strokeWidth={iconStroke} /></button> <FormatButton title="Numbered list" active={formatState.insertOrderedList} onClick={() => execFormat("insertOrderedList")}><ListOrdered size={14} strokeWidth={iconStroke} /></FormatButton>
</div> </div>
<span className="compose-format-divider compose-format-divider-advanced" /> <span className="compose-format-divider compose-format-divider-advanced" />
<div className="compose-format-group compose-format-advanced"> <div className="compose-format-group compose-format-advanced">
<button type="button" title="Indent" onClick={() => execFormat("indent")}><IndentIncrease size={14} strokeWidth={iconStroke} /></button> <FormatButton title="Indent" onClick={() => execFormat("indent")}><IndentIncrease size={14} strokeWidth={iconStroke} /></FormatButton>
<button type="button" title="Outdent" onClick={() => execFormat("outdent")}><IndentDecrease size={14} strokeWidth={iconStroke} /></button> <FormatButton title="Outdent" onClick={() => execFormat("outdent")}><IndentDecrease size={14} strokeWidth={iconStroke} /></FormatButton>
<button type="button" title="Quote" onClick={() => execFormat("formatBlock", "blockquote")}><Quote size={14} strokeWidth={iconStroke} /></button> <FormatButton title="Quote" onClick={() => execFormat("formatBlock", "blockquote")}><Quote size={14} strokeWidth={iconStroke} /></FormatButton>
<button type="button" title="Link" onClick={insertLink}><Link size={14} strokeWidth={iconStroke} /></button> <FormatButton title="Link" onClick={openLinkDialog}><Link size={14} strokeWidth={iconStroke} /></FormatButton>
<button type="button" title="Remove formatting" onClick={() => execFormat("removeFormat")}><RemoveFormatting size={14} strokeWidth={iconStroke} /></button> <FormatButton title="Remove formatting" onClick={() => execFormat("removeFormat")}><RemoveFormatting size={14} strokeWidth={iconStroke} /></FormatButton>
</div> </div>
</div> </div>
@ -680,11 +870,23 @@ export default function ComposeModal({
<button type="button" className="compose-icon-btn" title="Insert image" onClick={() => imageInputRef.current?.click()}> <button type="button" className="compose-icon-btn" title="Insert image" onClick={() => imageInputRef.current?.click()}>
<Image size={16} strokeWidth={iconStroke} /> <Image size={16} strokeWidth={iconStroke} />
</button> </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} /> <Link size={16} strokeWidth={iconStroke} />
</button> </button>
<div className="compose-emoji-wrap"> <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} /> <Smile size={16} strokeWidth={iconStroke} />
</button> </button>
{showEmoji && ( {showEmoji && (

View file

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

View file

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

View file

@ -21,6 +21,8 @@ import AgentSpark from "./AgentSpark";
import SenderAvatar from "./SenderAvatar"; import SenderAvatar from "./SenderAvatar";
import { addressText, senderDisplayName } from "../utils/senderAvatar"; import { addressText, senderDisplayName } from "../utils/senderAvatar";
import { attachmentCount } from "../utils/inboxSignals"; import { attachmentCount } from "../utils/inboxSignals";
import { visibleMessageHtml } from "../utils/emailReply";
import { formatMailDetailTime } from "../utils/datetime";
const TOOLBAR_ICON = { size: 16, strokeWidth: 1.7 }; const TOOLBAR_ICON = { size: 16, strokeWidth: 1.7 };
const ACTION_ICON = { size: 14, strokeWidth: 1.7 }; const ACTION_ICON = { size: 14, strokeWidth: 1.7 };
@ -44,12 +46,16 @@ function messagePreview(message) {
} }
function messageBody(email) { function messageBody(email) {
const preview = messagePreview(email); const html = visibleMessageHtml(email?.body_html || "");
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();
if (html) return { mode: "html", value: 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(); const snippet = (email?.snippet || "").trim();
if (snippet) return { mode: "text", value: snippet }; if (snippet) return { mode: "text", value: snippet };
return { mode: "text", value: "This message has no readable content." }; return { mode: "text", value: "This message has no readable content." };
@ -89,20 +95,9 @@ function MessageBodyContent({ message }) {
} }
function detailTime(email) { function detailTime(email) {
const value = email?.received_at || email?.sent_at || email?.created_at || email?.updated_at; return formatMailDetailTime(
if (!value) return { absolute: "", relative: "" }; email?.received_at || email?.sent_at || email?.created_at || email?.updated_at,
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 };
} }
function senderIdentity(email) { function senderIdentity(email) {

View file

@ -11,28 +11,10 @@ import AgentSpark from "./AgentSpark";
import SenderAvatar from "./SenderAvatar"; import SenderAvatar from "./SenderAvatar";
import { senderDisplayName } from "../utils/senderAvatar"; import { senderDisplayName } from "../utils/senderAvatar";
import { attachmentCount, emailLabels } from "../utils/inboxSignals"; import { attachmentCount, emailLabels } from "../utils/inboxSignals";
import { formatMailboxTime } from "../utils/datetime";
const ICON = { size: 15, strokeWidth: 1.75 }; 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({ export default function MailRow({
email, email,
selected, selected,
@ -129,7 +111,7 @@ export default function MailRow({
{files > 0 && <Paperclip size={14} strokeWidth={1.75} />} {files > 0 && <Paperclip size={14} strokeWidth={1.75} />}
</span> </span>
<div className="mailbox-row-rail"> <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"> <div className="mailbox-hover-actions" aria-label="Message actions">
<button type="button" title="Archive" aria-label="Archive" onClick={(event) => stop(event, () => onArchive?.(email))}> <button type="button" title="Archive" aria-label="Archive" onClick={(event) => stop(event, () => onArchive?.(email))}>
<Archive {...ICON} /> <Archive {...ICON} />

View file

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

View file

@ -6,8 +6,8 @@ import {
CircleHelp, CircleHelp,
Filter, Filter,
LayoutGrid, LayoutGrid,
List,
Mail, Mail,
MoreHorizontal,
RefreshCw, RefreshCw,
Search, Search,
Settings, Settings,
@ -46,7 +46,14 @@ import {
} from "../utils/inboxSignals"; } from "../utils/inboxSignals";
import { buildReplyBodyHtml } from "../utils/emailReply"; 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 = [ const CATEGORY_TABS = [
{ name: "Primary", icon: LayoutGrid }, { name: "Primary", icon: LayoutGrid },
{ name: "Updates", icon: Bell }, { name: "Updates", icon: Bell },
@ -57,10 +64,11 @@ const CATEGORY_TABS = [
]; ];
function resultPage(result) { 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 { return {
items: result.items || result.emails || result.results || [], items: result.items || result.emails || result.results || [],
nextCursor: result.next_cursor || result.nextCursor || null, 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 token = sessionStorage.getItem("agmail_access_token");
const [emails, setEmails] = useState([]); const [emails, setEmails] = useState([]);
const [nextCursor, setNextCursor] = useState(null); 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 [cursor, setCursor] = useState(null);
const [cursorHistory, setCursorHistory] = useState([]); const [cursorHistory, setCursorHistory] = useState([]);
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
@ -140,7 +151,7 @@ export default function MailboxView({
const result = await listThreads( const result = await listThreads(
{ {
mailbox, mailbox,
limit: PAGE_SIZE, limit: pageSize,
cursor, cursor,
q: filters.query.trim(), q: filters.query.trim(),
is_read: filters.read === "all" ? undefined : filters.read === "read", is_read: filters.read === "all" ? undefined : filters.read === "read",
@ -153,11 +164,12 @@ export default function MailboxView({
const page = resultPage(result); const page = resultPage(result);
setEmails(page.items.map(threadToRow)); setEmails(page.items.map(threadToRow));
setNextCursor(page.nextCursor); setNextCursor(page.nextCursor);
setTotal(page.total);
setStatus({ loading: false, error: "" }); setStatus({ loading: false, error: "" });
} catch (error) { } catch (error) {
setStatus({ loading: false, error: error.message }); setStatus({ loading: false, error: error.message });
} }
}, [cursor, filters, mailbox, token]); }, [cursor, filters, mailbox, pageSize, token]);
useEffect(() => { useEffect(() => {
const timeout = window.setTimeout(load, filters.query ? 250 : 0); const timeout = window.setTimeout(load, filters.query ? 250 : 0);
@ -250,6 +262,22 @@ export default function MailboxView({
}); });
}, [emails, onStats]); }, [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) { function changeFilter(name, value) {
setFilters((current) => ({ ...current, [name]: value })); setFilters((current) => ({ ...current, [name]: value }));
setCursor(null); setCursor(null);
@ -580,6 +608,7 @@ export default function MailboxView({
setCursor(nextCursor); setCursor(nextCursor);
setSelected(null); setSelected(null);
setDetail(null); setDetail(null);
setChecked({});
} }
function previousPage() { function previousPage() {
@ -589,11 +618,30 @@ export default function MailboxView({
setCursor(previous); setCursor(previous);
setSelected(null); setSelected(null);
setDetail(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 initials = `${account?.first_name?.[0] || ""}${account?.last_name?.[0] || ""}`.toUpperCase();
const rangeStart = listedEmails.length ? cursorHistory.length * PAGE_SIZE + 1 : 0; const rangeStart = emails.length ? cursorHistory.length * pageSize + 1 : 0;
const rangeEnd = cursorHistory.length * PAGE_SIZE + listedEmails.length; 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 ( return (
<section className="mailbox-workspace"> <section className="mailbox-workspace">
@ -652,10 +700,85 @@ export default function MailboxView({
<div className="mailbox-main-column"> <div className="mailbox-main-column">
<div className={`mailbox-shell${selected ? " detail-visible" : ""}`}> <div className={`mailbox-shell${selected ? " detail-visible" : ""}`}>
<div className="mailbox-list-pane"> <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 && ( {selectedCount > 0 && (
<MailboxSelectionBar <MailboxSelectionBar
count={selectedCount} count={selectedCount}
total={listedEmails.length} total={listedEmails.length}
hideSelectAll
onToggleAll={toggleSelectAll} onToggleAll={toggleSelectAll}
onArchive={() => runBulkAction("archive")} onArchive={() => runBulkAction("archive")}
onSpam={() => runBulkAction("spam")} onSpam={() => runBulkAction("spam")}
@ -755,32 +878,6 @@ export default function MailboxView({
))} ))}
</div> </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 <MailDetail
email={detail} email={detail}
thread={threadDetail} thread={threadDetail}

View file

@ -16,6 +16,7 @@ import greenPin from "../data/green.png";
import pinkPin from "../data/pink.png"; import pinkPin from "../data/pink.png";
import yellowPin from "../data/yellow.png"; import yellowPin from "../data/yellow.png";
import { senderDisplayName } from "../utils/senderAvatar"; import { senderDisplayName } from "../utils/senderAvatar";
import { formatAbsoluteDateTime } from "../utils/datetime";
const NOTE_COLORS = ["yellow", "green", "blue", "pink"]; const NOTE_COLORS = ["yellow", "green", "blue", "pink"];
const PIN_IMAGES = { const PIN_IMAGES = {
@ -25,18 +26,6 @@ const PIN_IMAGES = {
pink: pinkPin, 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) { function noteMatchesContext(note, contextEmail) {
const needle = senderDisplayName(contextEmail).toLowerCase(); const needle = senderDisplayName(contextEmail).toLowerCase();
if (!needle) return false; if (!needle) return false;
@ -84,7 +73,7 @@ function NoteCard({ note, onDelete, menuOpen, onMenuToggle }) {
<span key={tag}>{tag}</span> <span key={tag}>{tag}</span>
))} ))}
</div> </div>
<time dateTime={note.updated_at}>{formatNoteTime(note.updated_at)}</time> <time dateTime={note.updated_at}>{formatAbsoluteDateTime(note.updated_at)}</time>
</footer> </footer>
</article> </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 { 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 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; } .security-toggle input:checked + span { background:#7357F6; }
.human-handoff-page { max-width:910px; margin:0 auto; padding:8px 4px 32px; } .human-handoff-page { max-width:900px; margin:0; 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 { margin:0; }
.human-handoff-head h1 { margin:0 0 4px; font:700 26px/1.15 "Manrope"; color:#171424; } .handoff-page-title,
.human-handoff-head p { margin:0; max-width:700px; color:#77758a; font-size:12px; line-height:1.5; } .human-handoff-head h1.handoff-page-title {
.human-handoff-tabs { margin-top:16px; display:inline-flex; gap:6px; padding:4px; border:1px solid #ece9f2; border-radius:10px; background:#faf9fc; } margin:0;
.human-handoff-tabs button { height:32px; padding:0 14px; border:0; border-radius:8px; background:transparent; color:#6f6b7c; font-size:12px; font-weight:600; } font-family:"DM Sans",sans-serif;
.human-handoff-tabs button.active { background:#fff; color:#171424; box-shadow:0 1px 4px rgba(20,18,30,0.08); } font-size:26px;
.handoff-inbox, .handoff-settings, .handoff-conversation { margin-top:16px; } 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-lead { margin:0 0 14px; color:#77758a; font-size:12px; }
.handoff-inbox-list { display:grid; gap:10px; } .handoff-inbox-list {
.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); } overflow:hidden;
.handoff-inbox-row:hover { border-color:#ddd4ff; background:#fcfbff; } border:1px solid #E7E4EA;
.handoff-inbox-row-main strong { display:block; margin-bottom:4px; font:650 14px/1.3 "DM Sans"; color:#171424; } border-radius:11px;
.handoff-inbox-row-party { color:#6d5bd0; font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.3px; } background:#FFFFFF;
.handoff-inbox-row-snippet { margin:8px 0 0; color:#5a5562; font-size:12px; line-height:1.45; } box-shadow:0 1px 2px rgba(20,18,30,.02);
.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-row {
.handoff-inbox-status.status-waiting_for_human { background:#fff6e8; color:#b26a00; } 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-human_replied { background:#edf4ff; color:#2f67c5; }
.handoff-inbox-status.status-agent_replied { background:#edf9f4; color:#11966a; } .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 { 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-back-btn:hover { background:#f6f2ff; }
.handoff-conversation-head h2 { margin:0 0 10px; font:700 22px/1.2 "Manrope"; color:#171424; } .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 { border:0; background:transparent; color:#7b67c8; font:600 12px "DM Sans"; cursor:pointer; }
.mailbox-empty-link:hover { color:#5a3fe0; } .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"; } .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-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; } .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 { 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-range { font-weight:500; }
.mailbox-pagination-controls,.mailbox-pagination-density { display:flex; align-items:center; gap:var(--space-1); } .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; } .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 { min-width:0; display:none; position:relative; z-index:1; }
.mail-detail.open { display:block; } .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-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 24px; display:flex; align-items:center; gap:2px; border-bottom:1px solid #ECE9EE; background:#fff; } .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-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-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:hover { background:#f5f4f2; color:#241f2b; }
.mail-detail-toolbar button.destructive { color:#6f6b7c; } .mail-detail-toolbar button.destructive { color:#6f6b7c; }
.mail-detail-toolbar button.destructive:hover { background:#fff0ed; color:#e94d3d; } .mail-detail-toolbar button.destructive:hover { background:#fff0ed; color:#e94d3d; }
.mail-detail-sheet { height:auto; min-height:0; margin:0; background:#fff; } .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-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-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-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-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-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 { 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:hover { background:#f3efff; border-color:#ddd4ff; } .mail-detail-ai-chip svg,.mail-detail-ai-chip .agent-spark { color:inherit; opacity:.8; }
.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: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-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-meta { min-width:0; display:grid; gap:1px; }
.mail-detail-sender-line { display:flex; flex-wrap:wrap; align-items:baseline; gap:4px; } .mail-detail-sender-line { display:flex; flex-wrap:wrap; align-items:baseline; gap:5px; }
.mail-detail-sender-line strong { font:650 13px/1.35 "DM Sans"; color:#171424; } .mail-detail-sender-line strong { font:650 12.5px/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-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 { 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 svg { transition:transform var(--motion-fast) var(--ease-standard); }
.mail-detail-recipient-toggle.open svg { transform:rotate(180deg); } .mail-detail-recipient-toggle.open svg { transform:rotate(180deg); }
.mail-detail-recipient-toggle:hover { color:#4a4652; } .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 { 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:11px; line-height:1.4; } .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:4px; margin-left:auto; flex-shrink:0; } .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:11px; white-space:nowrap; } .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: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-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:hover { background:#f3f1f5; color:#241f2b; }
.mail-detail-message-action.active { color:#d4a017; } .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 { 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-x0); } .mail-detail-message-content.is-html { padding-left:var(--reader-x2); }
.mail-detail-body { color:#292536; } .mail-detail-body { max-width:760px; color:#241f2b; font-size:13px; line-height:1.55; }
.mail-detail-body.is-text { max-width:760px; font-size:13px; line-height:1.58; } .mail-detail-body-text { white-space:pre-wrap; overflow-wrap:anywhere; }
.mail-detail-body-text { white-space:pre-line; overflow-wrap:anywhere; } .mail-detail-body.is-html { max-width:none; }
.mail-detail-body.is-html { max-width:none; font-size:14px; line-height:1.55; } .mail-detail-body-html { white-space:normal; overflow-wrap:anywhere; }
.mail-detail-body-html { white-space:normal; } .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 p { margin:0 0 12px; } .mail-detail-body-html div { margin:0; }
.mail-detail-body-html a { color:#4d74c4; text-decoration:underline; } .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 { 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-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 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 { 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-attachment-actions button { padding:2px 4px; border:0; background:transparent; color:#7a7390; font-size:10px; font-weight:600; }
.mail-detail-message-footer { margin-top:24px; padding:0 0 24px; width:100%; box-sizing:border-box; } .mail-detail-message-footer { margin-top:18px; padding:0 0 18px; 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-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: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-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:hover { background:#f8f7f5; border-color:#d5d2da; }
.mail-detail-reply-btn.subtle { color:#5a5562; background:#fafafa; } .mail-detail-reply-btn.subtle { color:#5a5562; background:#fafafa; }
.mail-thread-messages.is-thread { padding:0 var(--reader-x0); } .mail-thread-messages.is-thread { padding:0; }
.mail-thread-message { padding:18px 0; border-top:1px solid #f0eef2; } .mail-thread-message { padding:0; border-top:0; }
.mail-thread-message:first-child { border-top:0; } .mail-thread-messages.is-thread .mail-thread-message { border-top:1px solid #EFEDF1; }
.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 .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:first-child { border-top:0; }
.mail-thread-message-collapsed:hover { background:#faf9fc; } .mail-thread-message-collapsed:hover { background:#faf9fc; }
.mail-thread-message-collapsed-main { min-width:0; display:grid; gap:2px; } .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-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 { 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-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; } .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-stat-grid,
.agent-panel.email-context .agent-context-block, .agent-panel.email-context .agent-context-block,
.agent-panel.email-context .agent-quick { display:none; } .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 { 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 h3 + h3 { margin-top:12px; }
.agent-email-context p { margin:0 0 12px; font-size:12px; line-height:1.55; color:#4a4652; } .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 { 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-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-action-check { width:14px; height:14px; flex-shrink:0; border:1.5px solid #d5d1da; 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 { 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:#f3efff; } .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 { 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; } .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 { 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 { 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-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.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.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); } .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); } } @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); } .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%); } } @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-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-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; } .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 { 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 { 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-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 { 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-label { color:#77717a; font-size:11px; font-weight:700; }
.compose-row-main { min-width:0; } .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-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-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-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: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-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 { 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; } .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 { 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 { width:26px; height:26px; border:0; border-radius:4px; background:transparent; font-size:15px; }
.compose-emoji-panel button:hover { background:#f3efec; } .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; } body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55); pointer-events:none; }
@media (max-width:640px) { @media (max-width:640px) {
.compose-format-divider-advanced,.compose-format-advanced { display:none; } .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-notes-pane { display:none; }
.mailbox-list-pane { border-right:0; } .mailbox-list-pane { border-right:0; }
.mailbox-shell.detail-visible .mailbox-toolbar, .mailbox-shell.detail-visible .mailbox-toolbar,
.mailbox-shell.detail-visible .mailbox-list-bar,
.mailbox-shell.detail-visible .mailbox-filters, .mailbox-shell.detail-visible .mailbox-filters,
.mailbox-shell.detail-visible .mailbox-list-footer { display:none; } .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-reader { --reader-x0:18px; --reader-avatar:34px; --reader-gap:8px; border-radius:8px; }
.mail-detail-toolbar { padding:0 16px; } .mail-detail-toolbar { padding:0 var(--reader-x0); }
.mail-detail-subject-row { flex-direction:column; align-items:flex-start; padding-top:18px; } .mail-detail-subject-row { flex-direction:column; align-items:flex-start; padding-top:14px; }
.mail-detail-ai-chips { justify-content:flex-start; } .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-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-message-content,.mail-detail-message-content.is-html { padding-left:var(--reader-x2); }
.mail-detail-reply-bar { padding-left:var(--reader-x0); } .mail-detail-reply-bar { padding-left:var(--reader-x2); }
.compose-dock { right:12px !important; bottom:12px; } .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)); } .compose-window.expanded { width:calc(100vw - 24px); min-width:0; height:min(78vh,calc(100vh - 24px)); }
} }
@media(max-width:480px) { @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-spark.pulse { animation:agentSparkPulse 1.2s ease-in-out infinite; }
.agent-label { display:inline-flex; align-items:center; gap:var(--space-1); } .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 { 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-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:var(--radius-md); background:#fff; box-shadow:0 1px 2px #08082108; } .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:#5a3fe0; font-size:var(--text-meta); font-weight:700; letter-spacing:.2px; text-transform:uppercase; } .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 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-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 { animation:agentReveal var(--motion-slow) var(--ease-standard) both; }
.agent-insight-block.reveal.delay-1 { animation-delay:80ms; } .agent-insight-block.reveal.delay-1 { animation-delay:80ms; }
.agent-insight-block.reveal.delay-2 { animation-delay:160ms; } .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-green { background:#e7f6ee; color:#2f7a52; }
.mail-label-pink { background:#fceeff; color:#8b5ca8; } .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; } .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 { --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:14px 14px 0; display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:10px; } .agent-panel-head { padding:12px 12px 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-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-title { min-width:0; }
.agent-panel-head h2 { margin:0; font:700 15px/18px "DM Sans"; color:#151322; letter-spacing:-.2px; } .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:#77758A; } .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:#30304A; cursor:pointer; transition:background 120ms ease; } .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:hover { background:#F5F4F2; }
.agent-panel-toggle .open { transform:rotate(180deg); } .agent-panel-toggle .open { transform:rotate(180deg); }
.agent-panel-body { padding:0 14px 14px; animation:agentReveal var(--motion-slow) var(--ease-standard); } .agent-panel-body { padding:0 12px 12px; animation:agentReveal var(--motion-slow) var(--ease-standard); }
.agent-panel-hello { margin:14px 0 0; display:grid; gap:2px; } .agent-panel-hello { margin:12px 0 0; display:grid; gap:2px; }
.agent-panel-hello strong { font:700 14px/18px "DM Sans"; color:#171424; } .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-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-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; } .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 .agent-stat-label { color:#77757D!important; }
.agent-stat-empty small { color:#B5B2BA!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 { 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-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 { 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:#F9F7FF; border-color:#D9D0FF; } .agent-quick-btn:hover { background:#FAF9FC; border-color:#E0DCE6; }
.agent-quick-icon { flex-shrink:0; } .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-muted { color:#667085; }
.agent-quick-icon-slate { color:#50627A; } .agent-quick-icon-slate { color:#50627A; }
.agent-context-block { margin-top:12px; padding-top:10px; border-top:1px solid var(--ag-divider); } .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 { 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-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:#5D4CF5; cursor:pointer; } .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-context-link:hover { text-decoration:underline; }
.agent-empty-line { margin:0; padding:2px 0; font:400 10px/14px "DM Sans"; color:#A6A2AE; } .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; } .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 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 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-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 { 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:#D9D0FF; box-shadow:0 0 0 2px rgba(115,87,246,0.12); } .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 { 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-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 { 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-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 { 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-ask-send:disabled { cursor:default; }
.agent-loading-block,.agent-loading-rows { display:grid; gap:6px; padding:4px 0; } .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; } .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([ const SAFE_TAGS = new Set([
"A", "A",
"B", "B",
@ -7,16 +9,23 @@ const SAFE_TAGS = new Set([
"CODE", "CODE",
"DIV", "DIV",
"EM", "EM",
"H1",
"H2",
"H3",
"I", "I",
"LI", "LI",
"OL", "OL",
"P", "P",
"PRE", "PRE",
"S",
"SPAN", "SPAN",
"STRIKE",
"STRONG", "STRONG",
"U", "U",
"UL", "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 = "") { export function escapeHtml(value = "") {
return value return value
@ -27,31 +36,117 @@ export function escapeHtml(value = "") {
.replaceAll("'", "&#39;"); .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 ""; if (!value) return "";
const doc = new DOMParser().parseFromString(value, "text/html"); const doc = new DOMParser().parseFromString(value, "text/html");
function clean(node) { function clean(node) {
[...node.childNodes].forEach((child) => { [...node.childNodes].forEach((child) => {
if (child.nodeType !== Node.ELEMENT_NODE) return; if (child.nodeType !== Node.ELEMENT_NODE) return;
if (!SAFE_TAGS.has(child.tagName)) { if (UNSAFE_TAGS.has(child.tagName)) {
if (["SCRIPT", "STYLE", "IFRAME", "OBJECT", "EMBED"].includes(child.tagName)) {
child.remove(); child.remove();
return; return;
} }
if (!SAFE_TAGS.has(child.tagName)) {
clean(child); clean(child);
child.replaceWith(...child.childNodes); child.replaceWith(...child.childNodes);
return; 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)); [...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") { if (child.tagName === "BLOCKQUOTE") {
child.setAttribute( child.setAttribute(
"style", "style",
"margin:0 0 0 .8ex;border-left:1px solid #ccc;padding-left:1ex", "margin:0 0 0 .8ex;border-left:1px solid #ccc;padding-left:1ex",
); );
} else if (styleText) {
child.setAttribute("style", styleText);
applySemanticStyles(child);
} }
clean(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?.sent_at
|| message?.created_at || message?.created_at
|| message?.updated_at; || message?.updated_at;
const date = value ? new Date(value) : null; const date = parseApiDate(value);
if (!date || Number.isNaN(date.getTime())) return ""; if (!date) return "";
const datePart = new Intl.DateTimeFormat(undefined, { const datePart = new Intl.DateTimeFormat(undefined, {
weekday: "short", weekday: "short",
@ -95,7 +190,7 @@ function attributionDate(message) {
} }
export function buildReplyBodyHtml(message) { export function buildReplyBodyHtml(message) {
const original = cleanMessageHtml(message?.body_html) const original = sanitizeEmailHtml(message?.body_html)
|| textBodyAsHtml(message?.body_text || message?.snippet || ""); || textBodyAsHtml(message?.body_text || message?.snippet || "");
const date = attributionDate(message); const date = attributionDate(message);
const identity = senderIdentity(message); const identity = senderIdentity(message);
@ -142,3 +237,16 @@ export function plainTextFromEmailHtml(value) {
.replace(/\n{3,}/g, "\n\n") .replace(/\n{3,}/g, "\n\n")
.trim(); .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 { senderDisplayName } from "./senderAvatar";
import { parseApiDate } from "./datetime";
const CATEGORY_ORDER = ["Primary", "Updates", "Promotions", "Social", "Finance"]; const CATEGORY_ORDER = ["Primary", "Updates", "Promotions", "Social", "Finance"];
@ -135,9 +136,7 @@ export function attachmentCount(email) {
function emailTimestamp(email) { function emailTimestamp(email) {
const value = email.received_at || email.sent_at || email.created_at || email.updated_at; const value = email.received_at || email.sent_at || email.created_at || email.updated_at;
if (!value) return null; return parseApiDate(value);
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
} }
function formatRelativeAge(date) { function formatRelativeAge(date) {