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