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 '
Loading...
'; + } + + 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.subject || "(no subject)") + "

" + + '
' + + "From " + escapeHtml(msg.sender) + "" + + "To " + escapeHtml(msg.recipient) + "" + + "Date " + new Date(msg.received_at).toLocaleString() + "" + + "
" + + "
" + + '
' + + 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 + + + +
+ +
+ +
+ + + + + +
+ + + + 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