diff --git a/app/main.py b/app/main.py
index 5bd191b..d75d1fb 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,5 +1,9 @@
+from pathlib import Path
+
from fastapi import FastAPI, Depends, HTTPException, status
+from fastapi.responses import FileResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from typing import List
import boto3
@@ -14,6 +18,12 @@
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Agent Suite", version="0.1.0")
+
+# Static files directory
+STATIC_DIR = Path(__file__).resolve().parent / "static"
+
+# Mount static assets (CSS, JS)
+app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
security = HTTPBearer()
settings = get_settings()
@@ -35,6 +45,35 @@ def verify_api_key(credentials: HTTPAuthorizationCredentials = Depends(security)
return inbox
+# ─── Web UI Routes ────────────────────────────────────────
+
+
+def _serve_ui():
+ """Serve the single-page application HTML."""
+ return FileResponse(str(STATIC_DIR / "index.html"), media_type="text/html")
+
+
+@app.get("/inbox", include_in_schema=False)
+def inbox_page():
+ """Inbox page - lists all received messages."""
+ return _serve_ui()
+
+
+@app.get("/inbox/{message_id}", include_in_schema=False)
+def message_page(message_id: str):
+ """Message detail page."""
+ return _serve_ui()
+
+
+@app.get("/compose", include_in_schema=False)
+def compose_page():
+ """Compose page - send a new email."""
+ return _serve_ui()
+
+
+# ─── API Routes ───────────────────────────────────────────
+
+
@app.get("/health")
def health_check():
return {"status": "ok", "service": "agent-suite"}
diff --git a/app/static/app.js b/app/static/app.js
new file mode 100644
index 0000000..a474f32
--- /dev/null
+++ b/app/static/app.js
@@ -0,0 +1,549 @@
+/* Agent Suite - Email Inbox Web UI */
+
+(function () {
+ "use strict";
+
+ // ─── State ───────────────────────────────────────────────
+
+ var state = {
+ apiKey: localStorage.getItem("agent_suite_api_key") || "",
+ messages: [],
+ total: 0,
+ currentPage: 0,
+ pageSize: 50,
+ };
+
+ // ─── DOM References ──────────────────────────────────────
+
+ var appEl = document.getElementById("app");
+ var modalOverlay = document.getElementById("modal-overlay");
+ var apiKeyInput = document.getElementById("api-key-input");
+ var showKeyCheckbox = document.getElementById("show-key");
+ var settingsBtn = document.getElementById("settings-btn");
+ var saveKeyBtn = document.getElementById("save-key-btn");
+
+ // ─── API Client ──────────────────────────────────────────
+
+ var api = {
+ request: function (path, options) {
+ options = options || {};
+ if (!state.apiKey) {
+ return Promise.reject(
+ new Error("No API key configured. Click the gear icon to set your API key.")
+ );
+ }
+ var headers = {
+ Authorization: "Bearer " + state.apiKey,
+ "Content-Type": "application/json",
+ };
+ if (options.headers) {
+ Object.keys(options.headers).forEach(function (k) {
+ headers[k] = options.headers[k];
+ });
+ }
+ return fetch(window.location.origin + path, {
+ method: options.method || "GET",
+ headers: headers,
+ body: options.body || undefined,
+ }).then(function (res) {
+ if (!res.ok) {
+ return res.json().catch(function () {
+ return {};
+ }).then(function (data) {
+ throw new Error(data.detail || "Request failed (" + res.status + ")");
+ });
+ }
+ return res.json();
+ });
+ },
+
+ getMessages: function (skip, limit) {
+ skip = skip || 0;
+ limit = limit || 50;
+ return api.request(
+ "/v1/inboxes/me/messages?skip=" + skip + "&limit=" + limit
+ );
+ },
+
+ getInbox: function () {
+ return api.request("/v1/inboxes/me");
+ },
+
+ sendEmail: function (to, subject, body) {
+ return api.request("/v1/inboxes/me/send", {
+ method: "POST",
+ body: JSON.stringify({ to: to, subject: subject, body: body }),
+ });
+ },
+ };
+
+ // ─── Toast Notifications ─────────────────────────────────
+
+ function showToast(message, type) {
+ type = type || "success";
+ var container = document.getElementById("toast-container");
+ var toast = document.createElement("div");
+ toast.className = "toast " + type;
+ toast.textContent = message;
+ container.appendChild(toast);
+ setTimeout(function () {
+ toast.style.opacity = "0";
+ toast.style.transition = "opacity 200ms";
+ setTimeout(function () {
+ if (toast.parentNode) {
+ toast.parentNode.removeChild(toast);
+ }
+ }, 200);
+ }, 4000);
+ }
+
+ // ─── Utility ─────────────────────────────────────────────
+
+ function formatDate(dateStr) {
+ var d = new Date(dateStr);
+ var now = new Date();
+ var diff = now - d;
+ if (diff < 60000) return "just now";
+ if (diff < 3600000) return Math.floor(diff / 60000) + "m ago";
+ if (diff < 86400000) return Math.floor(diff / 3600000) + "h ago";
+ if (diff < 604800000) return Math.floor(diff / 86400000) + "d ago";
+ return d.toLocaleDateString();
+ }
+
+ function escapeHtml(str) {
+ if (!str) return "";
+ var div = document.createElement("div");
+ div.appendChild(document.createTextNode(str));
+ return div.innerHTML;
+ }
+
+ function truncate(str, len) {
+ if (!str) return "";
+ return str.length > len ? str.substring(0, len) + "..." : str;
+ }
+
+ // ─── Views ───────────────────────────────────────────────
+
+ function renderLoading() {
+ return '
';
+ }
+
+ function renderError(message) {
+ return '' + escapeHtml(message) + "
";
+ }
+
+ function renderNoApiKey() {
+ return (
+ '' +
+ '
🔑
' +
+ "
API Key Required
" +
+ '
Set your API key using the gear icon in the navigation bar to access your inbox.
' +
+ "
"
+ );
+ }
+
+ function renderEmptyInbox() {
+ return (
+ '' +
+ '
📫
' +
+ "
No messages yet
" +
+ "
Your inbox is empty. Messages sent to your agent email address will appear here.
" +
+ "
"
+ );
+ }
+
+ // ─── Inbox View ──────────────────────────────────────────
+
+ function renderInboxView() {
+ updateActiveNav("/inbox");
+
+ if (!state.apiKey) {
+ appEl.innerHTML = renderNoApiKey();
+ return;
+ }
+
+ appEl.innerHTML =
+ '" +
+ renderLoading();
+
+ var refreshBtn = document.getElementById("refresh-btn");
+ if (refreshBtn) {
+ refreshBtn.addEventListener("click", function () {
+ loadMessages();
+ });
+ }
+
+ loadMessages();
+ }
+
+ function loadMessages() {
+ var skip = state.currentPage * state.pageSize;
+
+ api.getMessages(skip, state.pageSize)
+ .then(function (data) {
+ state.messages = data.messages;
+ state.total = data.total;
+ renderMessageList();
+ })
+ .catch(function (err) {
+ // Keep the page header, replace only the content area
+ var existing = appEl.querySelector(".message-list, .loading, .error-banner, .empty-state");
+ var html = renderError(err.message);
+ if (existing) {
+ existing.outerHTML = html;
+ } else {
+ appEl.innerHTML =
+ '" +
+ html;
+ }
+ });
+ }
+
+ function renderMessageList() {
+ var header =
+ '";
+
+ if (state.messages.length === 0) {
+ appEl.innerHTML = header + renderEmptyInbox();
+ bindRefresh();
+ return;
+ }
+
+ var items = state.messages
+ .map(function (msg) {
+ var unreadClass = msg.is_read ? "" : " unread";
+ var dotClass = msg.is_read ? "unread-dot read" : "unread-dot";
+ return (
+ '' +
+ '
' +
+ '
' +
+ '
' + escapeHtml(msg.sender) + "
" +
+ '
' + escapeHtml(msg.subject || "(no subject)") + "
" +
+ '
' + escapeHtml(truncate(msg.body_text, 100)) + "
" +
+ "
" +
+ '
' + formatDate(msg.received_at) + "
" +
+ "
"
+ );
+ })
+ .join("");
+
+ var pagination = "";
+ if (state.total > state.pageSize) {
+ var totalPages = Math.ceil(state.total / state.pageSize);
+ pagination =
+ '' +
+ '" +
+ '' +
+ "Page " + (state.currentPage + 1) + " of " + totalPages +
+ "" +
+ '" +
+ "
";
+ }
+
+ appEl.innerHTML =
+ header +
+ '' + items + "
" +
+ pagination;
+
+ bindRefresh();
+ bindPagination();
+ bindMessageClicks();
+ }
+
+ function bindRefresh() {
+ var refreshBtn = document.getElementById("refresh-btn");
+ if (refreshBtn) {
+ refreshBtn.addEventListener("click", function () {
+ loadMessages();
+ });
+ }
+ }
+
+ function bindPagination() {
+ var prevBtn = document.getElementById("prev-page");
+ var nextBtn = document.getElementById("next-page");
+ if (prevBtn) {
+ prevBtn.addEventListener("click", function () {
+ if (state.currentPage > 0) {
+ state.currentPage--;
+ loadMessages();
+ }
+ });
+ }
+ if (nextBtn) {
+ nextBtn.addEventListener("click", function () {
+ state.currentPage++;
+ loadMessages();
+ });
+ }
+ }
+
+ function bindMessageClicks() {
+ var items = appEl.querySelectorAll(".message-item");
+ items.forEach(function (item) {
+ item.addEventListener("click", function () {
+ var id = item.getAttribute("data-id");
+ navigate("/inbox/" + id);
+ });
+ });
+ }
+
+ // ─── Message Detail View ─────────────────────────────────
+
+ function renderMessageDetailView(messageId) {
+ updateActiveNav("/inbox");
+
+ // Find message in current state
+ var msg = state.messages.find(function (m) {
+ return m.id === messageId;
+ });
+
+ if (!msg) {
+ // If message not in state, try to reload messages first
+ if (!state.apiKey) {
+ appEl.innerHTML = renderNoApiKey();
+ return;
+ }
+ appEl.innerHTML = renderLoading();
+ api.getMessages(0, 200)
+ .then(function (data) {
+ state.messages = data.messages;
+ state.total = data.total;
+ msg = state.messages.find(function (m) {
+ return m.id === messageId;
+ });
+ if (msg) {
+ showMessageDetail(msg);
+ } else {
+ appEl.innerHTML =
+ renderError("Message not found.") +
+ '← Back to Inbox';
+ bindLinks();
+ }
+ })
+ .catch(function (err) {
+ appEl.innerHTML = renderError(err.message);
+ });
+ return;
+ }
+
+ showMessageDetail(msg);
+ }
+
+ function showMessageDetail(msg) {
+ appEl.innerHTML =
+ '← Back to Inbox' +
+ '' +
+ '" +
+ '
' +
+ escapeHtml(msg.body_text || "(no content)") +
+ "
" +
+ "
";
+
+ bindLinks();
+ }
+
+ // ─── Compose View ────────────────────────────────────────
+
+ function renderComposeView() {
+ updateActiveNav("/compose");
+
+ if (!state.apiKey) {
+ appEl.innerHTML = renderNoApiKey();
+ return;
+ }
+
+ appEl.innerHTML =
+ '' +
+ '";
+
+ var form = document.getElementById("compose-form");
+ var sendBtn = document.getElementById("send-btn");
+ var discardBtn = document.getElementById("discard-btn");
+
+ form.addEventListener("submit", function (e) {
+ e.preventDefault();
+ var to = document.getElementById("compose-to").value.trim();
+ var subject = document.getElementById("compose-subject").value.trim();
+ var body = document.getElementById("compose-body").value.trim();
+
+ if (!to || !body) {
+ showToast("Please fill in the recipient and message body.", "error");
+ return;
+ }
+
+ sendBtn.disabled = true;
+ sendBtn.textContent = "Sending...";
+
+ api.sendEmail(to, subject, body)
+ .then(function (res) {
+ showToast("Email sent to " + res.to);
+ navigate("/inbox");
+ })
+ .catch(function (err) {
+ showToast(err.message, "error");
+ sendBtn.disabled = false;
+ sendBtn.textContent = "Send";
+ });
+ });
+
+ discardBtn.addEventListener("click", function () {
+ navigate("/inbox");
+ });
+ }
+
+ // ─── Router ──────────────────────────────────────────────
+
+ function navigate(path) {
+ window.history.pushState({}, "", path);
+ route();
+ }
+
+ function route() {
+ var path = window.location.pathname;
+
+ if (path === "/compose") {
+ renderComposeView();
+ } else if (path.match(/^\/inbox\/[a-f0-9-]+$/)) {
+ var id = path.split("/inbox/")[1];
+ renderMessageDetailView(id);
+ } else {
+ // Default to inbox (covers /inbox, /, etc.)
+ renderInboxView();
+ }
+ }
+
+ function updateActiveNav(activePath) {
+ var links = document.querySelectorAll(".nav-link");
+ links.forEach(function (link) {
+ if (link.getAttribute("href") === activePath) {
+ link.classList.add("active");
+ } else {
+ link.classList.remove("active");
+ }
+ });
+ }
+
+ function bindLinks() {
+ document.querySelectorAll("[data-link]").forEach(function (link) {
+ // Remove existing listener by cloning
+ var newLink = link.cloneNode(true);
+ link.parentNode.replaceChild(newLink, link);
+ newLink.addEventListener("click", function (e) {
+ e.preventDefault();
+ navigate(newLink.getAttribute("href"));
+ });
+ });
+ }
+
+ // ─── Settings Modal ──────────────────────────────────────
+
+ function openModal() {
+ apiKeyInput.value = state.apiKey;
+ apiKeyInput.type = "password";
+ showKeyCheckbox.checked = false;
+ modalOverlay.classList.remove("hidden");
+ apiKeyInput.focus();
+ }
+
+ function closeModal() {
+ modalOverlay.classList.add("hidden");
+ }
+
+ function saveApiKey() {
+ var key = apiKeyInput.value.trim();
+ state.apiKey = key;
+ if (key) {
+ localStorage.setItem("agent_suite_api_key", key);
+ showToast("API key saved");
+ } else {
+ localStorage.removeItem("agent_suite_api_key");
+ showToast("API key cleared");
+ }
+ closeModal();
+ route(); // Re-render current view
+ }
+
+ // ─── Event Bindings ──────────────────────────────────────
+
+ // Navigation links (client-side routing)
+ document.addEventListener("click", function (e) {
+ var link = e.target.closest("[data-link]");
+ if (link) {
+ e.preventDefault();
+ navigate(link.getAttribute("href"));
+ }
+ });
+
+ // Browser back/forward
+ window.addEventListener("popstate", function () {
+ route();
+ });
+
+ // Settings modal
+ settingsBtn.addEventListener("click", openModal);
+ saveKeyBtn.addEventListener("click", saveApiKey);
+ apiKeyInput.addEventListener("keydown", function (e) {
+ if (e.key === "Enter") saveApiKey();
+ });
+
+ // Close modal on overlay click or close button
+ modalOverlay.addEventListener("click", function (e) {
+ if (e.target === modalOverlay || e.target.classList.contains("modal-close")) {
+ closeModal();
+ }
+ });
+
+ // Close modal on Escape
+ document.addEventListener("keydown", function (e) {
+ if (e.key === "Escape" && !modalOverlay.classList.contains("hidden")) {
+ closeModal();
+ }
+ });
+
+ // Show/hide API key
+ showKeyCheckbox.addEventListener("change", function () {
+ apiKeyInput.type = showKeyCheckbox.checked ? "text" : "password";
+ });
+
+ // ─── Initialize ──────────────────────────────────────────
+
+ route();
+})();
diff --git a/app/static/index.html b/app/static/index.html
new file mode 100644
index 0000000..87c2db3
--- /dev/null
+++ b/app/static/index.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+ Agent Suite - Inbox
+
+
+
+
+
+
+
+
+
+
+
+
+
Enter your API key to access your inbox. Create one via POST /v1/inboxes.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/static/style.css b/app/static/style.css
new file mode 100644
index 0000000..aa9317a
--- /dev/null
+++ b/app/static/style.css
@@ -0,0 +1,684 @@
+/* === Agent Suite Dark Theme === */
+
+:root {
+ --bg-primary: #0d1117;
+ --bg-secondary: #161b22;
+ --bg-tertiary: #21262d;
+ --border-color: #30363d;
+ --border-hover: #484f58;
+ --text-primary: #c9d1d9;
+ --text-secondary: #8b949e;
+ --text-muted: #6e7681;
+ --accent: #58a6ff;
+ --accent-hover: #79c0ff;
+ --success: #3fb950;
+ --error: #f85149;
+ --warning: #d29922;
+ --radius: 6px;
+ --radius-lg: 10px;
+ --shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
+ --transition: 150ms ease;
+}
+
+/* === Reset & Base === */
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html {
+ font-size: 16px;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
+ sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ line-height: 1.5;
+ min-height: 100vh;
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+ transition: color var(--transition);
+}
+
+a:hover {
+ color: var(--accent-hover);
+}
+
+code {
+ background: var(--bg-tertiary);
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 0.875em;
+}
+
+/* === Navigation === */
+
+header {
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border-color);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+nav {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 0 1rem;
+ height: 56px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.brand {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ white-space: nowrap;
+}
+
+.brand:hover {
+ color: var(--accent);
+}
+
+.nav-links {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.nav-link {
+ padding: 0.375rem 0.75rem;
+ border-radius: var(--radius);
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: background var(--transition), color var(--transition);
+}
+
+.nav-link:hover,
+.nav-link.active {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+/* === Main Content === */
+
+main {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 1.5rem 1rem;
+}
+
+/* === Buttons === */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.375rem;
+ padding: 0.5rem 1rem;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius);
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background var(--transition), border-color var(--transition);
+ white-space: nowrap;
+}
+
+.btn:hover {
+ background: var(--bg-secondary);
+ border-color: var(--border-hover);
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+
+.btn-primary:hover {
+ background: var(--accent-hover);
+ border-color: var(--accent-hover);
+}
+
+.btn-secondary {
+ background: transparent;
+ border-color: var(--border-color);
+}
+
+.btn-icon {
+ padding: 0.375rem;
+ border: none;
+ background: transparent;
+ font-size: 1.25rem;
+ cursor: pointer;
+ color: var(--text-secondary);
+ border-radius: var(--radius);
+ transition: background var(--transition), color var(--transition);
+ line-height: 1;
+}
+
+.btn-icon:hover {
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+}
+
+/* === Page Header === */
+
+.page-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.page-header h1 {
+ font-size: 1.5rem;
+ font-weight: 600;
+}
+
+.badge {
+ background: var(--bg-tertiary);
+ color: var(--text-secondary);
+ padding: 0.125rem 0.5rem;
+ border-radius: 10px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ margin-left: 0.5rem;
+}
+
+/* === Message List === */
+
+.message-list {
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+.message-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 0.875rem 1rem;
+ border-bottom: 1px solid var(--border-color);
+ cursor: pointer;
+ transition: background var(--transition);
+}
+
+.message-item:last-child {
+ border-bottom: none;
+}
+
+.message-item:hover {
+ background: var(--bg-secondary);
+}
+
+.message-item.unread {
+ background: var(--bg-secondary);
+}
+
+.message-item.unread .message-subject {
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.unread-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent);
+ flex-shrink: 0;
+ margin-top: 0.5rem;
+}
+
+.unread-dot.read {
+ background: transparent;
+}
+
+.message-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.message-sender {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.message-subject {
+ font-size: 0.9375rem;
+ color: var(--text-secondary);
+ font-weight: 400;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.message-preview {
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-top: 0.125rem;
+}
+
+.message-time {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+/* === Message Detail === */
+
+.message-detail {
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+.message-detail-header {
+ padding: 1.25rem;
+ border-bottom: 1px solid var(--border-color);
+ background: var(--bg-secondary);
+}
+
+.message-detail-header h2 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ word-break: break-word;
+}
+
+.message-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ font-size: 0.8125rem;
+ color: var(--text-secondary);
+}
+
+.message-meta span {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.message-meta .label {
+ color: var(--text-muted);
+ min-width: 3rem;
+}
+
+.message-detail-body {
+ padding: 1.25rem;
+ font-size: 0.9375rem;
+ line-height: 1.7;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.back-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ margin-bottom: 1rem;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+.back-link:hover {
+ color: var(--accent);
+}
+
+/* === Compose Form === */
+
+.compose-form {
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+.form-group {
+ padding: 0 1.25rem;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.form-group:last-of-type {
+ border-bottom: none;
+}
+
+.form-group label {
+ display: block;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding-top: 0.75rem;
+}
+
+.form-group input,
+.form-group textarea {
+ width: 100%;
+ background: transparent;
+ border: none;
+ color: var(--text-primary);
+ font-size: 0.9375rem;
+ font-family: inherit;
+ padding: 0.5rem 0 0.75rem;
+ outline: none;
+ resize: vertical;
+}
+
+.form-group textarea {
+ min-height: 200px;
+ line-height: 1.6;
+}
+
+.form-group input::placeholder,
+.form-group textarea::placeholder {
+ color: var(--text-muted);
+}
+
+.form-actions {
+ padding: 1rem 1.25rem;
+ border-top: 1px solid var(--border-color);
+ background: var(--bg-secondary);
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+}
+
+/* === States === */
+
+.empty-state {
+ text-align: center;
+ padding: 3rem 1rem;
+ color: var(--text-secondary);
+}
+
+.empty-state .icon {
+ font-size: 2.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.empty-state h3 {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin-bottom: 0.375rem;
+ color: var(--text-primary);
+}
+
+.empty-state p {
+ font-size: 0.875rem;
+ max-width: 320px;
+ margin: 0 auto;
+}
+
+.loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 3rem;
+ color: var(--text-secondary);
+}
+
+.spinner {
+ width: 20px;
+ height: 20px;
+ border: 2px solid var(--border-color);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+ margin-right: 0.75rem;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+.error-banner {
+ background: rgba(248, 81, 73, 0.1);
+ border: 1px solid rgba(248, 81, 73, 0.3);
+ color: var(--error);
+ padding: 0.75rem 1rem;
+ border-radius: var(--radius);
+ font-size: 0.875rem;
+ margin-bottom: 1rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.success-banner {
+ background: rgba(63, 185, 80, 0.1);
+ border: 1px solid rgba(63, 185, 80, 0.3);
+ color: var(--success);
+ padding: 0.75rem 1rem;
+ border-radius: var(--radius);
+ font-size: 0.875rem;
+ margin-bottom: 1rem;
+}
+
+/* === Modal === */
+
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 200;
+ padding: 1rem;
+}
+
+.modal-overlay.hidden {
+ display: none;
+}
+
+.modal {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ width: 100%;
+ max-width: 440px;
+ box-shadow: var(--shadow-lg);
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem 1.25rem;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.modal-header h2 {
+ font-size: 1rem;
+ font-weight: 600;
+}
+
+.modal-body {
+ padding: 1.25rem;
+}
+
+.modal-body label {
+ display: block;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.375rem;
+ margin-top: 0.75rem;
+}
+
+.modal-body input[type="password"],
+.modal-body input[type="text"] {
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius);
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ font-family: monospace;
+ outline: none;
+ transition: border-color var(--transition);
+}
+
+.modal-body input:focus {
+ border-color: var(--accent);
+}
+
+.modal-body .hint {
+ font-size: 0.8125rem;
+ color: var(--text-secondary);
+ margin-bottom: 0.25rem;
+}
+
+.checkbox-label {
+ display: flex !important;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.8125rem !important;
+ color: var(--text-secondary) !important;
+ text-transform: none !important;
+ letter-spacing: normal !important;
+ cursor: pointer;
+ margin-top: 0.5rem !important;
+}
+
+.checkbox-label input[type="checkbox"] {
+ width: auto;
+}
+
+.modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ padding: 1rem 1.25rem;
+ border-top: 1px solid var(--border-color);
+}
+
+/* === Toast Notifications === */
+
+#toast-container {
+ position: fixed;
+ bottom: 1.5rem;
+ right: 1.5rem;
+ z-index: 300;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.toast {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius);
+ padding: 0.75rem 1rem;
+ font-size: 0.875rem;
+ box-shadow: var(--shadow-lg);
+ animation: slideIn 200ms ease;
+ max-width: 360px;
+}
+
+.toast.success {
+ border-color: rgba(63, 185, 80, 0.4);
+}
+
+.toast.error {
+ border-color: rgba(248, 81, 73, 0.4);
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* === Responsive === */
+
+@media (max-width: 640px) {
+ nav {
+ padding: 0 0.75rem;
+ height: 48px;
+ }
+
+ .brand {
+ font-size: 1rem;
+ }
+
+ .nav-link {
+ padding: 0.25rem 0.5rem;
+ font-size: 0.8125rem;
+ }
+
+ main {
+ padding: 1rem 0.75rem;
+ }
+
+ .page-header h1 {
+ font-size: 1.25rem;
+ }
+
+ .message-item {
+ padding: 0.75rem;
+ }
+
+ .message-detail-header,
+ .message-detail-body {
+ padding: 1rem;
+ }
+
+ .form-group {
+ padding: 0 1rem;
+ }
+
+ .form-actions {
+ padding: 0.75rem 1rem;
+ }
+
+ .modal {
+ max-width: 100%;
+ }
+}
+
+@media (max-width: 400px) {
+ .message-time {
+ display: none;
+ }
+}
diff --git a/tests/test_api.py b/tests/test_api.py
index 08f4976..4135218 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -75,7 +75,7 @@ def test_list_messages(setup_db):
create_resp = client.post("/v1/inboxes")
api_key = create_resp.json()["api_key"]
email = create_resp.json()["email_address"]
-
+
# Simulate incoming message via webhook
client.post(
"/v1/webhooks/mailgun",
@@ -87,7 +87,7 @@ def test_list_messages(setup_db):
"message_id": "test123"
}
)
-
+
# List messages
response = client.get(
"/v1/inboxes/me/messages",
@@ -97,3 +97,60 @@ def test_list_messages(setup_db):
data = response.json()
assert data["total"] == 1
assert data["messages"][0]["subject"] == "Test Subject"
+
+
+# ─── Web UI Tests ─────────────────────────────────────────
+
+
+def test_inbox_page():
+ """GET /inbox returns the SPA HTML page."""
+ response = client.get("/inbox")
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+ assert "Agent Suite" in response.text
+
+
+def test_compose_page():
+ """GET /compose returns the SPA HTML page."""
+ response = client.get("/compose")
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+ assert "Agent Suite" in response.text
+
+
+def test_message_detail_page():
+ """GET /inbox/{id} returns the SPA HTML page."""
+ response = client.get("/inbox/00000000-0000-0000-0000-000000000000")
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+
+
+def test_static_css():
+ """Static CSS file is served correctly."""
+ response = client.get("/static/style.css")
+ assert response.status_code == 200
+ assert "text/css" in response.headers["content-type"]
+
+
+def test_static_js():
+ """Static JS file is served correctly."""
+ response = client.get("/static/app.js")
+ assert response.status_code == 200
+ assert "javascript" in response.headers["content-type"]
+
+
+def test_inbox_page_contains_key_elements():
+ """Verify the HTML page includes essential UI elements."""
+ response = client.get("/inbox")
+ html = response.text
+ # Navigation links
+ assert 'href="/inbox"' in html
+ assert 'href="/compose"' in html
+ # Settings modal
+ assert 'id="modal-overlay"' in html
+ assert 'id="api-key-input"' in html
+ # App container
+ assert 'id="app"' in html
+ # Static asset references
+ assert "/static/style.css" in html
+ assert "/static/app.js" in html