first commit

This commit is contained in:
debu-2 2026-10-03 21:14:44 +05:30
commit cce9a8c9ba
73 changed files with 8665 additions and 0 deletions

View file

@ -0,0 +1,27 @@
import { ArrowLeft, Bot, ShieldCheck, Sparkles } from "lucide-react";
import Brand from "./Brand";
export default function AuthLayout({ children, eyebrow, title, description }) {
return (
<main className="auth-page">
<nav className="auth-nav">
<Brand />
<a className="back-link" href="/"><ArrowLeft size={15} /> Back home</a>
</nav>
<div className="auth-orb auth-orb-one" />
<div className="auth-orb auth-orb-two" />
<section className="auth-shell">
<div className="auth-story">
<div className="eyebrow"><Sparkles size={13} /> {eyebrow}</div>
<h1>{title}</h1>
<p>{description}</p>
<div className="auth-points">
<span><Bot size={17} /> Ready for your AI agents</span>
<span><ShieldCheck size={17} /> Private by design</span>
</div>
</div>
<div className="auth-card">{children}</div>
</section>
</main>
);
}

View file

@ -0,0 +1,11 @@
export default function Brand({ compact = false }) {
return (
<a className={`brand${compact ? " brand-compact" : ""}`} href="/" aria-label="Agmail home">
<span className="brand-mark" aria-hidden="true">
<span className="robot-eyes"><i /><i /></span>
<span className="robot-smile" />
</span>
{!compact && <span>agmail</span>}
</a>
);
}

View file

@ -0,0 +1,139 @@
import { ArrowRight, CheckCircle2, Copy, XCircle } from "lucide-react";
import { useState } from "react";
import { verifyCompanyDns } from "../api";
import Brand from "./Brand";
function DnsRecordTable({ records }) {
async function copyValue(value) {
try {
await navigator.clipboard.writeText(value);
} catch {
/* clipboard may be unavailable */
}
}
return (
<div className="dns-table-wrap">
<table className="dns-table">
<thead>
<tr>
<th>Type</th>
<th>Host</th>
<th>Value</th>
<th>TTL</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const rowKey = `${record.record_type}-${record.host}-${record.purpose}`;
const displayValue = record.record_type === "MX"
? `10 ${record.value}`
: record.value;
return (
<tr key={rowKey}>
<td>{record.record_type}</td>
<td><code>{record.host}</code></td>
<td>
<div className="dns-value-cell">
<code>{displayValue}</code>
<button
type="button"
className="dns-copy"
aria-label="Copy value"
onClick={() => copyValue(displayValue)}
>
<Copy size={14} />
</button>
</div>
</td>
<td>{record.ttl}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
export default function CompanyDnsSetup({ domain, account, onComplete, onLogout }) {
const token = sessionStorage.getItem("agmail_access_token");
const [status, setStatus] = useState({ loading: false, error: "" });
const [checks, setChecks] = useState(null);
async function handleVerify() {
setStatus({ loading: true, error: "" });
setChecks(null);
try {
const result = await verifyCompanyDns(token);
setChecks(result.checks);
if (result.verified) {
setStatus({ loading: false, error: "" });
onComplete(result);
return;
}
setStatus({
loading: false,
error: "Some DNS records are missing or still propagating. Update your registrar and try again.",
});
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
return (
<main className="company-dns-setup">
<header className="company-dns-header">
<Brand />
<button type="button" onClick={onLogout}>Log out</button>
</header>
<section className="company-dns-card">
<p className="eyebrow">Company setup</p>
<h1>Connect <em>{domain.domain_name}</em></h1>
<p className="company-dns-lead">
Add these DNS records at your domain registrar for <strong>{account.email}</strong>.
Mail uses shared hosting at <strong>{domain.mail_host}</strong>, like Zoho or Namecheap Private Email.
</p>
<DnsRecordTable records={domain.dns_records} />
<ul className="dns-notes">
<li>Point MX to <strong>{domain.mail_host}</strong> — you do not need a separate mail A record.</li>
<li>SPF uses <code>include:_spf.getall.help</code> — that record must exist on <strong>getall.help</strong> (Host <code>_spf</code>, TXT <code>v=spf1 ip4:&lt;mail-server-ip&gt; ~all</code>).</li>
<li>DNS changes can take a few minutes to propagate.</li>
</ul>
{checks && checks.length > 0 && (
<div className="company-dns-checks">
{checks.map((check) => (
<div
key={check.purpose}
className={`company-dns-check${check.verified ? " ok" : " fail"}`}
>
{check.verified ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
<div>
<strong>{check.purpose.toUpperCase()}</strong>
<span>{check.message}</span>
</div>
</div>
))}
</div>
)}
{status.error && (
<p className="form-error" role="alert">{status.error}</p>
)}
<button
className="button button-coral company-dns-next"
type="button"
disabled={status.loading}
onClick={handleVerify}
>
{status.loading ? "Checking DNS…" : "Next — verify DNS"} <ArrowRight size={16} />
</button>
</section>
</main>
);
}

View file

@ -0,0 +1,166 @@
import {
Bold,
Italic,
Link,
List,
Send,
Sparkles,
Underline,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { sendEmail } from "../api";
function parseAddresses(value) {
return value
.split(/[,\n;]/)
.map((address) => address.trim())
.filter(Boolean);
}
function plainTextFromHtml(value) {
const document = new DOMParser().parseFromString(value, "text/html");
return document.body.textContent || "";
}
export default function ComposeModal({ account, onClose, onSent }) {
const [format, setFormat] = useState("plain");
const [showCopies, setShowCopies] = useState(false);
const [body, setBody] = useState("");
const [aiPrompt, setAiPrompt] = useState("");
const [status, setStatus] = useState({ sending: false, error: "" });
const bodyRef = useRef(null);
useEffect(() => {
function closeOnEscape(event) {
if (event.key === "Escape" && !status.sending) onClose();
}
document.addEventListener("keydown", closeOnEscape);
return () => document.removeEventListener("keydown", closeOnEscape);
}, [onClose, status.sending]);
function helpWrite() {
const request = aiPrompt.trim();
if (!request) return;
const draft = format === "html"
? `<p>Hi,</p><p>${request}</p><p>Best,<br>${account.first_name}</p>`
: `Hi,\n\n${request}\n\nBest,\n${account.first_name}`;
setBody(draft);
setAiPrompt("");
}
function applyHtml(openingTag, closingTag) {
const editor = bodyRef.current;
if (!editor || format !== "html") return;
const start = editor.selectionStart;
const end = editor.selectionEnd;
const selected = body.slice(start, end) || "text";
setBody(`${body.slice(0, start)}${openingTag}${selected}${closingTag}${body.slice(end)}`);
window.requestAnimationFrame(() => editor.focus());
}
async function submit(event) {
event.preventDefault();
const form = new FormData(event.currentTarget);
setStatus({ sending: true, error: "" });
try {
await sendEmail(
{
recipients: parseAddresses(form.get("to")),
cc: parseAddresses(form.get("cc") || ""),
bcc: parseAddresses(form.get("bcc") || ""),
subject: form.get("subject"),
body_text: format === "plain" ? body : plainTextFromHtml(body),
body_html: format === "html" ? body : null,
},
sessionStorage.getItem("agmail_access_token"),
);
onSent();
} catch (error) {
setStatus({ sending: false, error: error.message });
}
}
return (
<div className="compose-overlay" role="presentation">
<section className="compose-modal" role="dialog" aria-modal="true" aria-labelledby="compose-title">
<header className="compose-header">
<div>
<span><Sparkles size={14} /></span>
<div><h2 id="compose-title">New message</h2><small>From {account.email}</small></div>
</div>
<button type="button" aria-label="Close compose" onClick={onClose} disabled={status.sending}>
<X size={20} />
</button>
</header>
<form className="compose-form" onSubmit={submit}>
<div className="compose-recipient-row">
<label htmlFor="compose-to">To</label>
<input id="compose-to" name="to" type="text" placeholder="name@example.com" required autoFocus />
<button type="button" onClick={() => setShowCopies((visible) => !visible)}>Cc · Bcc</button>
</div>
{showCopies && (
<div className="compose-copy-fields">
<label>Cc<input name="cc" type="text" placeholder="Copy recipients" /></label>
<label>Bcc<input name="bcc" type="text" placeholder="Hidden recipients" /></label>
</div>
)}
<input className="compose-subject" name="subject" placeholder="Subject" maxLength="998" />
<div className="compose-format-row">
<span>Message format</span>
<div role="tablist" aria-label="Message format">
<button className={format === "plain" ? "active" : ""} type="button" onClick={() => setFormat("plain")}>Plain</button>
<button className={format === "html" ? "active" : ""} type="button" onClick={() => setFormat("html")}>HTML</button>
</div>
</div>
<textarea
className="compose-body"
ref={bodyRef}
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={format === "html" ? "<p>Write your HTML email…</p>" : "Write your message…"}
required
/>
<div className="compose-tools" aria-label="Message tools">
<button type="button" title="Bold" disabled={format !== "html"} onClick={() => applyHtml("<strong>", "</strong>")}><Bold size={16} /></button>
<button type="button" title="Italic" disabled={format !== "html"} onClick={() => applyHtml("<em>", "</em>")}><Italic size={16} /></button>
<button type="button" title="Underline" disabled={format !== "html"} onClick={() => applyHtml("<u>", "</u>")}><Underline size={16} /></button>
<button type="button" title="List" disabled={format !== "html"} onClick={() => applyHtml("<ul><li>", "</li></ul>")}><List size={17} /></button>
<button type="button" title="Add link" disabled={format !== "html"} onClick={() => applyHtml('<a href="https://">', "</a>")}><Link size={17} /></button>
{format === "plain" && <span>Formatting tools are available in HTML mode</span>}
</div>
<div className="compose-ai">
<Sparkles size={18} />
<input
value={aiPrompt}
onChange={(event) => setAiPrompt(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
helpWrite();
}
}}
placeholder="Describe what you want to say"
/>
<button type="button" onClick={helpWrite} disabled={!aiPrompt.trim()}>
Help me write
</button>
</div>
{status.error && <p className="compose-error" role="alert">{status.error}</p>}
<footer className="compose-footer">
<span>Sent securely through agmail SMTP</span>
<button className="compose-send" type="submit" disabled={status.sending}>
{status.sending ? "Sending…" : "Send"} <Send size={16} />
</button>
</footer>
</form>
</section>
</div>
);
}

View file

@ -0,0 +1,213 @@
import {
Bot,
Clock3,
Inbox,
ListFilter,
LogOut,
Mail,
Menu,
Plug,
Send,
Settings,
ShieldAlert,
Sparkles,
Tags,
Trash2,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import { getCurrentAccount, getMyCompanyDomain } from "../api";
import Brand from "./Brand";
import CompanyDnsSetup from "./CompanyDnsSetup";
import ComposeModal from "./ComposeModal";
import MailboxView from "./MailboxView";
const mailItems = [
[Inbox, "Inbox"],
[Send, "Outbox"],
[ShieldAlert, "Spam"],
[Trash2, "Trash"],
[Clock3, "Scheduled"],
[Settings, "Settings"],
];
const agentItems = [
[Tags, "Tags"],
[ListFilter, "Filters"],
[Sparkles, "Summary"],
[Plug, "Integration"],
];
function clearSession() {
sessionStorage.removeItem("agmail_access_token");
sessionStorage.removeItem("agmail_account");
}
function NavGroup({ icon: Icon, title, items, active, onSelect }) {
return (
<section className="dashboard-nav-group">
<h2><Icon size={15} />{title}</h2>
<nav aria-label={title}>
{items.map(([ItemIcon, label]) => (
<button
className={active === label ? "active" : ""}
key={label}
onClick={() => onSelect(label)}
type="button"
>
<ItemIcon size={17} />
<span>{label}</span>
</button>
))}
</nav>
</section>
);
}
export default function DashboardPage() {
const [account, setAccount] = useState(null);
const [companyDomain, setCompanyDomain] = useState(null);
const [needsDnsSetup, setNeedsDnsSetup] = useState(false);
const [domainError, setDomainError] = useState("");
const [active, setActive] = useState("Inbox");
const [menuOpen, setMenuOpen] = useState(false);
const [composeOpen, setComposeOpen] = useState(false);
const [notice, setNotice] = useState("");
const [loading, setLoading] = useState(true);
const [mailRefreshKey, setMailRefreshKey] = useState(0);
useEffect(() => {
const token = sessionStorage.getItem("agmail_access_token");
if (!token) {
window.location.replace("/login");
return;
}
getCurrentAccount(token)
.then(async (currentAccount) => {
sessionStorage.setItem("agmail_account", JSON.stringify(currentAccount));
setAccount(currentAccount);
if (currentAccount.account_type === "company") {
try {
const domain = await getMyCompanyDomain(token);
setCompanyDomain(domain);
setNeedsDnsSetup(domain.status === "pending_dns");
} catch (error) {
setDomainError(error.message || "Could not load your company domain.");
}
}
setLoading(false);
})
.catch(() => {
clearSession();
window.location.replace("/login");
});
}, []);
function logout() {
clearSession();
window.location.assign("/login");
}
function selectItem(item) {
setActive(item);
setMenuOpen(false);
}
function emailSent() {
setComposeOpen(false);
setActive("Outbox");
setMailRefreshKey((key) => key + 1);
setNotice("Message accepted by the mail server. Delivery can take a few minutes — check spam if it does not arrive.");
window.setTimeout(() => setNotice(""), 4000);
}
if (loading || !account) {
return <main className="dashboard-loading"><Brand /><span>Opening your workspace…</span></main>;
}
if (account.account_type === "company" && domainError) {
return (
<main className="company-dns-setup">
<header className="company-dns-header">
<Brand />
<button type="button" onClick={logout}>Log out</button>
</header>
<section className="company-dns-card">
<p className="eyebrow">Company setup</p>
<h1>Domain not ready</h1>
<p className="form-error" role="alert">{domainError}</p>
</section>
</main>
);
}
if (needsDnsSetup && companyDomain) {
return (
<CompanyDnsSetup
account={account}
domain={companyDomain}
onComplete={() => {
setCompanyDomain({ ...companyDomain, status: "active" });
setNeedsDnsSetup(false);
}}
onLogout={logout}
/>
);
}
const workspaceLabel = account.account_type === "company" ? "Company" : "Individual";
const workspaceTitle = account.account_type === "company" && companyDomain
? companyDomain.domain_name
: "Personal workspace";
return (
<div className="dashboard">
<header className="dashboard-mobile-header">
<Brand />
<button aria-label="Open navigation" type="button" onClick={() => setMenuOpen(true)}>
<Menu size={21} />
</button>
</header>
{menuOpen && <button className="dashboard-scrim" aria-label="Close navigation" onClick={() => setMenuOpen(false)} />}
<aside className={menuOpen ? "dashboard-sidebar open" : "dashboard-sidebar"}>
<div className="dashboard-brand-row">
<Brand />
<button aria-label="Close navigation" type="button" onClick={() => setMenuOpen(false)}><X size={19} /></button>
</div>
<button className="dashboard-compose" type="button" onClick={() => setComposeOpen(true)}>
<Mail size={17} />Compose
</button>
<NavGroup icon={Mail} title="Mail" items={mailItems} active={active} onSelect={selectItem} />
<NavGroup icon={Bot} title="Agent" items={agentItems} active={active} onSelect={selectItem} />
<div className="dashboard-profile">
<span>{account.first_name[0]}{account.last_name[0]}</span>
<div><strong>{account.first_name} {account.last_name}</strong><small>{account.email}</small></div>
<button aria-label="Log out" title="Log out" type="button" onClick={logout}><LogOut size={17} /></button>
</div>
</aside>
<main className="dashboard-content">
{!["Inbox", "Outbox", "Trash"].includes(active) && (
<div className="dashboard-content-heading">
<div><p>{workspaceTitle}</p><h1>{active}</h1></div>
<span className="dashboard-account-pill">{workspaceLabel}</span>
</div>
)}
{active === "Inbox" && <MailboxView mailbox="inbox" title="Inbox" refreshKey={mailRefreshKey} />}
{active === "Outbox" && <MailboxView mailbox="sent" title="Sent" refreshKey={mailRefreshKey} />}
{active === "Trash" && <MailboxView mailbox="trash" title="Trash" refreshKey={mailRefreshKey} />}
{!["Inbox", "Outbox", "Trash"].includes(active) && (
<section className="dashboard-empty">
<span><Sparkles size={24} /></span>
<h2>{active} is ready for you</h2>
<p>Your personal mail and agent tools will appear here.</p>
</section>
)}
</main>
{notice && <div className="dashboard-notice" role="status">{notice}</div>}
{composeOpen && <ComposeModal account={account} onClose={() => setComposeOpen(false)} onSent={emailSent} />}
</div>
);
}

View file

@ -0,0 +1,46 @@
import { ArrowRight, LockKeyhole, Mail } from "lucide-react";
import { useState } from "react";
import { login } from "../api";
import AuthLayout from "./AuthLayout";
export default function LoginPage() {
const [status, setStatus] = useState({ loading: false, error: "" });
async function handleSubmit(event) {
event.preventDefault();
setStatus({ loading: true, error: "" });
const form = new FormData(event.currentTarget);
try {
const result = await login({
email: form.get("email"),
password: form.get("password"),
});
sessionStorage.setItem("agmail_access_token", result.access_token);
sessionStorage.setItem("agmail_account", JSON.stringify(result.account));
window.location.assign("/app");
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
return (
<AuthLayout
eyebrow="Welcome back"
title={<>Your inbox<br /><em>missed you.</em></>}
description="Sign in to continue working alongside your agents."
>
<>
<div className="card-heading"><h2>Log in to agmail</h2><p>Enter your account details below.</p></div>
<form className="auth-form" onSubmit={handleSubmit}>
<label>Email address<div className="input-wrap"><Mail size={16} /><input name="email" type="email" placeholder="you@getall.help" required autoComplete="email" /></div></label>
<label>Password<div className="input-wrap"><LockKeyhole size={16} /><input name="password" type="password" placeholder="Your password" required autoComplete="current-password" /></div></label>
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<button className="button button-coral auth-submit" disabled={status.loading}>
{status.loading ? "Signing in…" : "Log in"} <ArrowRight size={16} />
</button>
</form>
<p className="auth-switch">New to agmail? <a href="/signup">Create an account</a></p>
</>
</AuthLayout>
);
}

View file

@ -0,0 +1,110 @@
import {
ArrowLeft,
Mail,
MailOpen,
RotateCcw,
Trash2,
X,
} from "lucide-react";
import SenderAvatar from "./SenderAvatar";
import { senderDisplayName } from "../utils/senderAvatar";
function joinAddresses(value) {
if (!value) return "";
const addresses = Array.isArray(value) ? value : [value];
return addresses
.map((item) => (typeof item === "string" ? item : item?.email || item?.address))
.filter(Boolean)
.join(", ");
}
function detailDate(email) {
const value =
email?.received_at || email?.sent_at || email?.created_at || email?.updated_at;
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
export default function MailDetail({
email,
loading,
error,
mailbox,
busy,
onClose,
onToggleRead,
onTrash,
onRestore,
onDelete,
}) {
return (
<article className={`mail-detail${email || loading ? " open" : ""}`} aria-live="polite">
<header className="mail-detail-toolbar">
<button type="button" onClick={onClose} aria-label="Back to message list">
<ArrowLeft size={18} />
</button>
<div>
{email && (
<button
type="button"
disabled={busy}
onClick={() => onToggleRead(!email.is_read)}
title={email.is_read ? "Mark unread" : "Mark read"}
aria-label={email.is_read ? "Mark unread" : "Mark read"}
>
{email.is_read ? <Mail size={17} /> : <MailOpen size={17} />}
</button>
)}
{email && mailbox === "trash" && (
<button type="button" disabled={busy} onClick={onRestore} title="Restore" aria-label="Restore message">
<RotateCcw size={17} />
</button>
)}
{email && (
<button
className={mailbox === "trash" ? "danger" : ""}
type="button"
disabled={busy}
onClick={mailbox === "trash" ? onDelete : onTrash}
title={mailbox === "trash" ? "Delete permanently" : "Move to trash"}
aria-label={mailbox === "trash" ? "Delete message permanently" : "Move message to trash"}
>
<Trash2 size={17} />
</button>
)}
<button className="mail-detail-close" type="button" onClick={onClose} aria-label="Close message">
<X size={18} />
</button>
</div>
</header>
{loading && <div className="mail-detail-state">Opening message…</div>}
{error && <div className="mail-detail-state error" role="alert">{error}</div>}
{email && !loading && (
<div className="mail-detail-content">
<h2>{email.subject?.trim() || "(No subject)"}</h2>
<div className="mail-detail-meta">
<div>
<div className="mail-detail-sender">
<SenderAvatar email={email} />
<strong>{senderDisplayName(email)}</strong>
</div>
<small>From: {joinAddresses(email.sender) || "Unknown"}</small>
<small>To: {joinAddresses(email.recipients || email.to) || "Undisclosed"}</small>
{joinAddresses(email.cc) && <small>Cc: {joinAddresses(email.cc)}</small>}
</div>
<time>{detailDate(email)}</time>
</div>
<div className="mail-detail-body">
{email.body_text || email.text_body || email.plain_text || "This message has no plain-text content."}
</div>
</div>
)}
</article>
);
}

View file

@ -0,0 +1,75 @@
import { MailOpen, Star } from "lucide-react";
import SenderAvatar from "./SenderAvatar";
import { senderDisplayName } from "../utils/senderAvatar";
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({ email, selected, onOpen, onToggleStar }) {
const unread = email.is_read === false;
const starred = email.is_starred === true;
const subject = email.subject?.trim() || "(No subject)";
const snippet = email.snippet || email.preview || email.body_text || "";
const timestamp =
email.received_at || email.sent_at || email.created_at || email.updated_at;
const senderName = senderDisplayName(email);
function handleKeyDown(event) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen(email);
}
}
function handleStarClick(event) {
event.stopPropagation();
onToggleStar?.(email, !starred);
}
return (
<div
className={`mailbox-row${unread ? " unread" : ""}${selected ? " selected" : ""}${starred ? " starred" : ""}`}
role="button"
tabIndex={0}
onClick={() => onOpen(email)}
onKeyDown={handleKeyDown}
aria-label={`${unread ? "Unread" : "Read"} email from ${senderName}: ${subject}`}
>
<span className="mailbox-read-dot" aria-hidden="true">
{unread ? <i /> : <MailOpen size={14} />}
</span>
<SenderAvatar email={email} className="mailbox-avatar" />
<span className="mailbox-sender">{senderName}</span>
<span className="mailbox-summary">
<strong>{subject}</strong>
{snippet && <span><b aria-hidden="true">—</b> {snippet}</span>}
</span>
<time dateTime={timestamp || undefined}>{formatTimestamp(timestamp)}</time>
<button
type="button"
className={`mailbox-star${starred ? " active" : ""}`}
onClick={handleStarClick}
aria-label={starred ? "Unstar message" : "Star message"}
aria-pressed={starred}
>
<Star size={15} fill={starred ? "currentColor" : "none"} />
</button>
</div>
);
}

View file

@ -0,0 +1,304 @@
import {
ChevronLeft,
ChevronRight,
Filter,
Inbox,
RefreshCw,
Search,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
getEmail,
listEmails,
permanentlyDeleteEmail,
restoreEmail,
setEmailRead,
setEmailStar,
trashEmail,
} from "../api";
import MailDetail from "./MailDetail";
import MailRow from "./MailRow";
import StickyNotesPanel from "./StickyNotesPanel";
const PAGE_SIZE = 25;
function resultPage(result) {
if (Array.isArray(result)) return { items: result, nextCursor: null };
return {
items: result.items || result.emails || result.results || [],
nextCursor: result.next_cursor || result.nextCursor || null,
};
}
export default function MailboxView({ mailbox, title, refreshKey = 0 }) {
const token = sessionStorage.getItem("agmail_access_token");
const [emails, setEmails] = useState([]);
const [nextCursor, setNextCursor] = useState(null);
const [cursor, setCursor] = useState(null);
const [cursorHistory, setCursorHistory] = useState([]);
const [selected, setSelected] = useState(null);
const [detail, setDetail] = useState(null);
const [status, setStatus] = useState({ loading: true, error: "" });
const [detailStatus, setDetailStatus] = useState({ loading: false, error: "", busy: false });
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState({
query: "",
read: "all",
sender: "",
dateFrom: "",
dateTo: "",
});
const load = useCallback(async () => {
setStatus({ loading: true, error: "" });
try {
const result = await listEmails(
{
mailbox,
limit: PAGE_SIZE,
cursor,
q: filters.query.trim(),
is_read: filters.read === "all" ? undefined : filters.read === "read",
sender: filters.sender.trim(),
date_from: filters.dateFrom || undefined,
date_to: filters.dateTo || undefined,
},
token,
);
const page = resultPage(result);
setEmails(page.items);
setNextCursor(page.nextCursor);
setStatus({ loading: false, error: "" });
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}, [cursor, filters, mailbox, token]);
useEffect(() => {
const timeout = window.setTimeout(load, filters.query ? 250 : 0);
return () => window.clearTimeout(timeout);
}, [load, refreshKey]);
function changeFilter(name, value) {
setFilters((current) => ({ ...current, [name]: value }));
setCursor(null);
setCursorHistory([]);
}
async function openEmail(email) {
setSelected(email.id);
setDetail(email);
setDetailStatus({ loading: true, error: "", busy: false });
if (email.is_read === false) {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_read: true } : item),
);
setDetail((current) => ({ ...current, is_read: true }));
setEmailRead(email.id, true, token).catch(() => {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_read: false } : item),
);
setDetail((current) =>
current?.id === email.id ? { ...current, is_read: false } : current,
);
});
}
try {
const completeEmail = await getEmail(email.id, token);
setDetail((current) => ({ ...current, ...completeEmail, is_read: current?.is_read ?? completeEmail.is_read }));
setDetailStatus({ loading: false, error: "", busy: false });
} catch (error) {
setDetailStatus({ loading: false, error: error.message, busy: false });
}
}
async function toggleStar(email, isStarred) {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_starred: isStarred } : item),
);
if (detail?.id === email.id) {
setDetail((current) => ({ ...current, is_starred: isStarred }));
}
try {
await setEmailStar(email.id, isStarred, token);
} catch (error) {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_starred: !isStarred } : item),
);
if (detail?.id === email.id) {
setDetail((current) => ({ ...current, is_starred: !isStarred }));
}
}
}
async function toggleRead(isRead) {
if (!detail) return;
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
try {
await setEmailRead(detail.id, isRead, token);
setDetail((current) => ({ ...current, is_read: isRead }));
setEmails((current) =>
current.map((item) => item.id === detail.id ? { ...item, is_read: isRead } : item),
);
setDetailStatus((current) => ({ ...current, busy: false }));
} catch (error) {
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
}
}
async function mutateSelected(action) {
if (!detail) return;
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
try {
await action(detail.id, token);
setEmails((current) => current.filter((item) => item.id !== detail.id));
setSelected(null);
setDetail(null);
setDetailStatus({ loading: false, error: "", busy: false });
} catch (error) {
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
}
}
function deletePermanently() {
if (window.confirm("Permanently delete this message? This cannot be undone.")) {
mutateSelected(permanentlyDeleteEmail);
}
}
function nextPage() {
if (!nextCursor) return;
setCursorHistory((history) => [...history, cursor]);
setCursor(nextCursor);
setSelected(null);
setDetail(null);
}
function previousPage() {
if (!cursorHistory.length) return;
const previous = cursorHistory[cursorHistory.length - 1];
setCursorHistory((history) => history.slice(0, -1));
setCursor(previous);
setSelected(null);
setDetail(null);
}
const hasFilters =
filters.read !== "all" || filters.sender || filters.dateFrom || filters.dateTo;
return (
<section className="mailbox-workspace">
<div className="mailbox-main-column">
<div className="dashboard-content-heading">
<div><p>Personal workspace</p><h1>{title}</h1></div>
<span className="dashboard-account-pill">Individual</span>
</div>
<div className={`mailbox-shell${selected ? " detail-visible" : ""}`}>
<div className="mailbox-list-pane">
<div className="mailbox-toolbar">
<label>
<Search size={16} />
<input
type="search"
value={filters.query}
onChange={(event) => changeFilter("query", event.target.value)}
placeholder={`Search ${title.toLowerCase()}`}
/>
</label>
<button
className={filtersOpen || hasFilters ? "active" : ""}
type="button"
onClick={() => setFiltersOpen((open) => !open)}
aria-expanded={filtersOpen}
>
<Filter size={16} /> <span>Filters</span>
</button>
<button type="button" onClick={load} aria-label="Refresh messages" title="Refresh">
<RefreshCw size={16} />
</button>
</div>
{filtersOpen && (
<div className="mailbox-filters">
<label>Read status
<select value={filters.read} onChange={(event) => changeFilter("read", event.target.value)}>
<option value="all">All messages</option>
<option value="unread">Unread</option>
<option value="read">Read</option>
</select>
</label>
<label>Sender
<input value={filters.sender} onChange={(event) => changeFilter("sender", event.target.value)} placeholder="name@example.com" />
</label>
<label>From
<input type="date" value={filters.dateFrom} onChange={(event) => changeFilter("dateFrom", event.target.value)} />
</label>
<label>To
<input type="date" value={filters.dateTo} onChange={(event) => changeFilter("dateTo", event.target.value)} />
</label>
</div>
)}
<div className="mailbox-rows" aria-busy={status.loading}>
{status.loading && <div className="mailbox-state">Loading messages…</div>}
{status.error && (
<div className="mailbox-state error" role="alert">
<p>{status.error}</p><button type="button" onClick={load}>Try again</button>
</div>
)}
{!status.loading && !status.error && emails.length === 0 && (
<div className="mailbox-state empty">
<span><Inbox size={22} /></span>
<strong>No messages here</strong>
<p>{hasFilters || filters.query ? "Try changing your search or filters." : `Your ${title.toLowerCase()} is empty.`}</p>
</div>
)}
{!status.loading && !status.error && emails.map((email) => (
<MailRow
key={email.id}
email={email}
selected={selected === email.id}
onOpen={openEmail}
onToggleStar={toggleStar}
/>
))}
</div>
{!status.loading && !status.error && emails.length > 0 && (
<footer className="mailbox-list-footer">
<p>
{emails.length} message{emails.length === 1 ? "" : "s"} in this view
{filters.query.trim() ? " · filtered" : ""}
</p>
<span>You're all caught up</span>
</footer>
)}
{(cursorHistory.length > 0 || nextCursor) && (
<footer className="mailbox-pagination">
<button type="button" disabled={!cursorHistory.length} onClick={previousPage}><ChevronLeft size={16} /> Previous</button>
<span>Page {cursorHistory.length + 1}</span>
<button type="button" disabled={!nextCursor} onClick={nextPage}>Next <ChevronRight size={16} /></button>
</footer>
)}
<MailDetail
email={detail}
loading={detailStatus.loading}
error={detailStatus.error}
busy={detailStatus.busy}
mailbox={mailbox}
onClose={() => { setSelected(null); setDetail(null); }}
onToggleRead={toggleRead}
onTrash={() => mutateSelected(trashEmail)}
onRestore={() => mutateSelected(restoreEmail)}
onDelete={deletePermanently}
/>
</div>
</div>
</div>
<StickyNotesPanel />
</section>
);
}

View file

@ -0,0 +1,34 @@
import { ArrowRight, Bot, Check, Inbox, Menu, Send, Sparkles } from "lucide-react";
import Brand from "./Brand";
export default function MobileLanding() {
return (
<main className="mobile-landing">
<nav className="mobile-nav">
<Brand />
<button className="icon-button" aria-label="Open menu"><Menu size={21} /></button>
</nav>
<section className="mobile-hero">
<div className="eyebrow"><Sparkles size={13} /> Email for humans + agents</div>
<h1>Your inbox,<br /><em>one step ahead.</em></h1>
<p>A focused home for every message, task, and AI agent you work with.</p>
<a className="button button-coral" href="/signup">Create your agmail <ArrowRight size={17} /></a>
<a className="mobile-login" href="/login">Already have an account? <strong>Log in</strong></a>
</section>
<section className="phone-preview" aria-label="Agmail mobile inbox preview">
<div className="phone-top"><span>9:41</span><span className="phone-pill" /><span>•••</span></div>
<header><div><small>Good morning</small><strong>Alex</strong></div><span className="avatar">AK</span></header>
<div className="mobile-summary">
<span className="agent-icon"><Bot size={18} /></span>
<div><strong>Your agent caught you up</strong><small>8 emails handled this morning</small></div>
<ArrowRight size={15} />
</div>
<div className="mobile-inbox-title"><h3>Priority inbox</h3><span>4 new</span></div>
<article className="mobile-mail"><span className="sender-avatar coral">R</span><div><strong>Research agent</strong><p>Market brief is ready</p></div><time>10:42</time></article>
<article className="mobile-mail"><span className="sender-avatar blue">M</span><div><strong>Maya Chen</strong><p>Re: Product direction</p></div><time>9:18</time></article>
<div className="mobile-tabbar"><span className="active"><Inbox size={19} /></span><button><span>+</span></button><span><Send size={19} /></span></div>
</section>
<section className="mobile-benefits"><span><Check size={15} /> Free to start</span><span><Check size={15} /> Private by design</span></section>
</main>
);
}

View file

@ -0,0 +1,23 @@
import { senderAvatar } from "../utils/senderAvatar";
export default function SenderAvatar({ email, className = "" }) {
const { name, initials, color, imageUrl } = senderAvatar(email);
if (imageUrl) {
return (
<span className={`sender-avatar${className ? ` ${className}` : ""}`}>
<img src={imageUrl} alt="" aria-hidden="true" />
</span>
);
}
return (
<span
className={`sender-avatar${className ? ` ${className}` : ""}`}
style={{ backgroundColor: color }}
aria-hidden="true"
>
{initials}
</span>
);
}

View file

@ -0,0 +1,80 @@
import {
ArrowRight, Building2, CalendarDays, Eye, LockKeyhole, Mail, Phone, UserRound,
} from "lucide-react";
import { useState } from "react";
import { register } from "../api";
import AuthLayout from "./AuthLayout";
export default function SignupPage() {
const [accountType, setAccountType] = useState("individual");
const [status, setStatus] = useState({ loading: false, error: "" });
async function handleSubmit(event) {
event.preventDefault();
setStatus({ loading: true, error: "" });
const form = new FormData(event.currentTarget);
try {
const result = await register({
account_type: form.get("account_type"),
email_name: form.get("email_name"),
company_domain: form.get("company_domain") || null,
first_name: form.get("first_name"),
last_name: form.get("last_name"),
date_of_birth: form.get("date_of_birth"),
phone_number: form.get("phone_number"),
password: form.get("password"),
});
sessionStorage.setItem("agmail_access_token", result.access_token);
sessionStorage.setItem("agmail_account", JSON.stringify(result.account));
window.location.assign("/app");
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
return (
<AuthLayout
eyebrow="Your new digital address"
title={<>An inbox built<br /><em>around you.</em></>}
description="Create a personal address or bring your company's domain."
>
<>
<div className="card-heading"><span>01 — Account setup</span><h2>Create your account</h2><p>Every field is securely processed by agmail.</p></div>
<form className="auth-form signup-form" onSubmit={handleSubmit}>
<fieldset>
<legend>Account type</legend>
<div className="account-types">
<label className={accountType === "individual" ? "type-card selected" : "type-card"}>
<input type="radio" name="account_type" value="individual" checked={accountType === "individual"} onChange={(event) => setAccountType(event.target.value)} />
<UserRound size={19} /><span><strong>Individual</strong><small>Your personal agent inbox</small></span>
</label>
<label className={accountType === "company" ? "type-card selected" : "type-card"}>
<input type="radio" name="account_type" value="company" checked={accountType === "company"} onChange={(event) => setAccountType(event.target.value)} />
<Building2 size={19} /><span><strong>Company</strong><small>Use your business domain</small></span>
</label>
</div>
</fieldset>
{accountType === "company" && (
<label>Company domain<div className="input-wrap"><Building2 size={16} /><input name="company_domain" placeholder="yourcompany.com" required /></div></label>
)}
<label>Choose your email name
<div className="email-builder input-wrap"><Mail size={16} /><input name="email_name" placeholder="alex" pattern="[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?" required /><span>{accountType === "individual" ? "@getall.help" : "@your domain"}</span></div>
</label>
<div className="field-row">
<label>First name<div className="input-wrap"><UserRound size={16} /><input name="first_name" placeholder="Alex" required /></div></label>
<label>Last name<div className="input-wrap"><UserRound size={16} /><input name="last_name" placeholder="Morgan" required /></div></label>
</div>
<label>Date of birth<div className="input-wrap"><CalendarDays size={16} /><input name="date_of_birth" type="date" required /></div></label>
<label>Phone number<div className="input-wrap"><Phone size={16} /><input name="phone_number" type="tel" placeholder="+1 555 123 4567" required /></div></label>
<label>Password<div className="input-wrap"><LockKeyhole size={16} /><input name="password" type="password" placeholder="8+ characters" minLength="8" required autoComplete="new-password" /><Eye size={15} /></div><small className="field-hint">Use uppercase, lowercase, and a number.</small></label>
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<button className="button button-coral auth-submit" disabled={status.loading}>
{status.loading ? "Creating account…" : "Create account"} <ArrowRight size={16} />
</button>
</form>
<p className="auth-switch">Already have an account? <a href="/login">Log in</a></p>
</>
</AuthLayout>
);
}

View file

@ -0,0 +1,271 @@
import { ArrowRight, Copy, Globe, Server } from "lucide-react";
import { useEffect, useState } from "react";
import { previewDnsSetup } from "../api";
import AuthLayout from "./AuthLayout";
const STORAGE_KEY = "agmail_dns_setup_auth";
function loadStoredAuth() {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (!parsed?.username || !parsed?.password) {
return null;
}
return parsed;
} catch {
return null;
}
}
function DnsRecordTable({ title, records }) {
if (!records?.length) {
return null;
}
async function copyValue(value) {
try {
await navigator.clipboard.writeText(value);
} catch {
/* clipboard may be unavailable */
}
}
return (
<section className="dns-section">
<h3>{title}</h3>
<div className="dns-table-wrap">
<table className="dns-table">
<thead>
<tr>
<th>Type</th>
<th>Host</th>
<th>Value</th>
<th>TTL</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const rowKey = `${record.record_type}-${record.host}-${record.purpose}`;
const displayValue = record.priority
? `${record.priority} ${record.value}`
: record.value;
return (
<tr key={rowKey}>
<td>{record.record_type}</td>
<td><code>{record.host}</code></td>
<td>
<div className="dns-value-cell">
<code>{displayValue}</code>
<button
type="button"
className="dns-copy"
aria-label="Copy value"
onClick={() => copyValue(displayValue)}
>
<Copy size={14} />
</button>
</div>
</td>
<td>{record.ttl}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
);
}
export default function SmtpCredentialsPage() {
const [auth, setAuth] = useState(() => loadStoredAuth());
const [status, setStatus] = useState({ loading: false, error: "" });
const [result, setResult] = useState(null);
useEffect(() => {
if (auth) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
} else {
sessionStorage.removeItem(STORAGE_KEY);
setResult(null);
}
}, [auth]);
async function handleLogin(event) {
event.preventDefault();
setStatus({ loading: true, error: "" });
const form = new FormData(event.currentTarget);
const nextAuth = {
username: String(form.get("username") || ""),
password: String(form.get("password") || ""),
};
try {
await previewDnsSetup(
{ domain: "example.com", mail_server_ip: "127.0.0.1" },
nextAuth,
);
setAuth(nextAuth);
setStatus({ loading: false, error: "" });
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
async function handleGenerate(event) {
event.preventDefault();
if (!auth) {
return;
}
setStatus({ loading: true, error: "" });
const form = new FormData(event.currentTarget);
try {
const data = await previewDnsSetup(
{
domain: String(form.get("domain") || ""),
mail_server_ip: String(form.get("mail_server_ip") || ""),
},
auth,
);
setResult(data);
setStatus({ loading: false, error: "" });
} catch (error) {
if (error.message.toLowerCase().includes("credential")) {
setAuth(null);
}
setStatus({ loading: false, error: error.message });
}
}
if (!auth) {
return (
<AuthLayout
eyebrow="Operations"
title={<>DNS setup<br /><em>for mail.</em></>}
description="Sign in to generate Namecheap-ready DNS records for self-hosted inbound and outbound mail."
>
<>
<div className="card-heading">
<h2>DNS setup access</h2>
<p>Restricted to deployment operators.</p>
</div>
<form className="auth-form" onSubmit={handleLogin}>
<label>
Username
<div className="input-wrap">
<Server size={16} />
<input
name="username"
type="text"
placeholder="smtp-credentia"
required
autoComplete="username"
/>
</div>
</label>
<label>
Passkey
<div className="input-wrap">
<Globe size={16} />
<input
name="password"
type="password"
placeholder="Your passkey"
required
autoComplete="current-password"
/>
</div>
</label>
{status.error && (
<p className="form-error" role="alert">{status.error}</p>
)}
<button className="button button-coral auth-submit" disabled={status.loading}>
{status.loading ? "Checking…" : "Continue"} <ArrowRight size={16} />
</button>
</form>
</>
</AuthLayout>
);
}
return (
<AuthLayout
eyebrow="Operations"
title={<>DNS records<br /><em>ready to paste.</em></>}
description="Enter the master domain and this server's public IP. Records are formatted for Namecheap Advanced DNS."
>
<>
<div className="card-heading">
<h2>Generate DNS configuration</h2>
<p>Inbound delivery and outbound authentication records for this mail host.</p>
</div>
<form className="auth-form" onSubmit={handleGenerate}>
<label>
Master domain
<div className="input-wrap">
<Globe size={16} />
<input
name="domain"
type="text"
placeholder="getall.help"
defaultValue={result?.domain || "getall.help"}
required
/>
</div>
</label>
<label>
Mail server public IP
<div className="input-wrap">
<Server size={16} />
<input
name="mail_server_ip"
type="text"
placeholder="203.0.113.10"
defaultValue={result?.mail_server_ip || ""}
required
/>
</div>
</label>
{status.error && (
<p className="form-error" role="alert">{status.error}</p>
)}
<button className="button button-coral auth-submit" disabled={status.loading}>
{status.loading ? "Generating…" : "Generate DNS records"} <ArrowRight size={16} />
</button>
</form>
{result && (
<div className="dns-results">
<p className="dns-summary">
<strong>{result.mail_host}</strong> → {result.mail_server_ip}
{result.app_public_host && (
<> · web UI at <strong>{result.app_public_host}</strong></>
)}
{" · self-hosted inbound + outbound"}
</p>
<ul className="dns-notes">
{result.notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
<DnsRecordTable
title="Inbound (MX, A — deliver mail to this host)"
records={result.sections.inbound}
/>
<DnsRecordTable
title="Outbound (SPF, DKIM, DMARC — authenticate sending)"
records={result.sections.outbound}
/>
</div>
)}
<p className="auth-switch">
<button type="button" className="link-button" onClick={() => setAuth(null)}>
Sign out
</button>
</p>
</>
</AuthLayout>
);
}

View file

@ -0,0 +1,283 @@
import {
MoreHorizontal,
Pin,
Plus,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
createStickyNote,
deleteStickyNote,
listStickyNotes,
} from "../api";
import bluePin from "../data/blue.png";
import greenPin from "../data/green.png";
import pinkPin from "../data/pink.png";
import yellowPin from "../data/yellow.png";
const NOTE_COLORS = ["yellow", "green", "blue", "pink"];
const PIN_IMAGES = {
yellow: yellowPin,
green: greenPin,
blue: bluePin,
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 NoteCard({ note, onDelete, menuOpen, onMenuToggle }) {
return (
<article className={`sticky-note sticky-note-${note.color}`}>
<span className="sticky-note-pin" aria-hidden="true">
<img src={PIN_IMAGES[note.color] || yellowPin} alt="" />
</span>
<header className="sticky-note-header">
<h3>{note.title}</h3>
<div className="sticky-note-menu-wrap">
<button
type="button"
className="sticky-note-menu-btn"
aria-label="Note options"
aria-expanded={menuOpen}
onClick={(event) => {
event.stopPropagation();
onMenuToggle(note.id);
}}
>
<MoreHorizontal size={16} />
</button>
{menuOpen && (
<div className="sticky-note-menu">
<button type="button" onClick={() => onDelete(note.id)}>
<Trash2 size={14} /> Delete
</button>
</div>
)}
</div>
</header>
{note.body && <p>{note.body}</p>}
<footer className="sticky-note-footer">
<div className="sticky-note-tags">
{(note.tags.length ? note.tags : ["General"]).map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
<time dateTime={note.updated_at}>{formatNoteTime(note.updated_at)}</time>
</footer>
</article>
);
}
export default function StickyNotesPanel() {
const token = sessionStorage.getItem("agmail_access_token");
const [notes, setNotes] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [adding, setAdding] = useState(false);
const [menuOpenId, setMenuOpenId] = useState(null);
const [draft, setDraft] = useState({
title: "",
body: "",
tags: "",
color: "yellow",
});
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const items = await listStickyNotes(token);
setNotes(items);
} catch (loadError) {
setError(loadError.message);
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
function closeMenu() {
setMenuOpenId(null);
}
window.addEventListener("click", closeMenu);
return () => window.removeEventListener("click", closeMenu);
}, []);
async function handleCreate(event) {
event.preventDefault();
if (!draft.title.trim()) return;
setSaving(true);
setError("");
try {
const created = await createStickyNote(
{
title: draft.title.trim(),
body: draft.body.trim(),
tags: draft.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
color: draft.color,
},
token,
);
setNotes((current) => [created, ...current]);
setDraft({
title: "",
body: "",
tags: "",
color: NOTE_COLORS[(notes.length + 1) % NOTE_COLORS.length],
});
setAdding(false);
} catch (createError) {
setError(createError.message);
} finally {
setSaving(false);
}
}
async function handleDelete(noteId) {
setMenuOpenId(null);
setError("");
try {
await deleteStickyNote(noteId, token);
setNotes((current) => current.filter((note) => note.id !== noteId));
} catch (deleteError) {
setError(deleteError.message);
}
}
function handleBodyKeyDown(event) {
if (event.key !== "Enter" || event.shiftKey) return;
event.preventDefault();
const textarea = event.currentTarget;
const { selectionStart, selectionEnd, value } = textarea;
const insertion = "\n• ";
const nextValue =
value.slice(0, selectionStart) + insertion + value.slice(selectionEnd);
setDraft((current) => ({ ...current, body: nextValue }));
window.requestAnimationFrame(() => {
textarea.selectionStart = selectionStart + insertion.length;
textarea.selectionEnd = selectionStart + insertion.length;
});
}
return (
<aside className="mailbox-notes-pane" aria-label="Sticky notes">
<header className="sticky-notes-header">
<div className="sticky-notes-title">
<Pin size={15} />
<h2>Sticky Notes</h2>
</div>
<div className="sticky-notes-actions">
<button
type="button"
aria-label="Add sticky note"
onClick={() => {
setDraft((current) => ({
...current,
color: NOTE_COLORS[notes.length % NOTE_COLORS.length],
}));
setAdding(true);
}}
>
<Plus size={18} />
</button>
</div>
</header>
<div className="sticky-notes-body">
{loading && <div className="sticky-notes-state">Loading notes…</div>}
{error && !loading && (
<div className="sticky-notes-state error" role="alert">{error}</div>
)}
{!loading && notes.map((note) => (
<NoteCard
key={note.id}
note={note}
menuOpen={menuOpenId === note.id}
onMenuToggle={(id) => setMenuOpenId((current) => (current === id ? null : id))}
onDelete={handleDelete}
/>
))}
{adding ? (
<form className="sticky-note-form" onSubmit={handleCreate}>
<input
type="text"
value={draft.title}
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
placeholder="Note title"
autoFocus
required
/>
<textarea
value={draft.body}
onChange={(event) => setDraft((current) => ({ ...current, body: event.target.value }))}
onKeyDown={handleBodyKeyDown}
placeholder="Jot down a quick note…"
rows={3}
/>
<input
type="text"
value={draft.tags}
onChange={(event) => setDraft((current) => ({ ...current, tags: event.target.value }))}
placeholder="Tags (comma separated)"
/>
<div className="sticky-note-colors">
{NOTE_COLORS.map((color) => (
<button
key={color}
type="button"
className={`sticky-note-color${draft.color === color ? " active" : ""}`}
data-color={color}
aria-label={`${color} note`}
onClick={() => setDraft((current) => ({ ...current, color }))}
/>
))}
</div>
<div className="sticky-note-form-actions">
<button type="button" onClick={() => setAdding(false)} disabled={saving}>
Cancel
</button>
<button type="submit" disabled={saving || !draft.title.trim()}>
{saving ? "Saving…" : "Save note"}
</button>
</div>
</form>
) : (
<button
type="button"
className="sticky-note-add"
onClick={() => {
setDraft((current) => ({
...current,
color: NOTE_COLORS[notes.length % NOTE_COLORS.length],
}));
setAdding(true);
}}
>
<Plus size={20} />
<span>Add sticky note</span>
<small>Jot down a quick note…</small>
</button>
)}
</div>
</aside>
);
}

View file

@ -0,0 +1,65 @@
import {
ArrowRight, Bot, Check, ChevronDown, Clock3, Inbox, Menu,
Paperclip, Search, Send, ShieldCheck, Sparkles,
} from "lucide-react";
import Brand from "./Brand";
const mail = [
{ sender: "Research agent", subject: "Market brief is ready", time: "10:42", color: "coral" },
{ sender: "Maya Chen", subject: "Re: Product direction", time: "9:18", color: "blue" },
{ sender: "Calendar", subject: "Design review in 30 minutes", time: "8:30", color: "gold" },
];
export default function WebLanding() {
return (
<main className="landing">
<nav className="nav shell">
<Brand />
<div className="nav-links"><a href="#product">Product</a><a href="#security">Security</a><a href="#about">About</a></div>
<div className="nav-actions"><a className="button button-quiet" href="/login">Log in</a><a className="button button-dark" href="/signup">Create account <ArrowRight size={15} /></a></div>
</nav>
<section className="hero shell">
<div className="hero-copy">
<div className="eyebrow"><Sparkles size={14} /> Built for you and your agents</div>
<h1>Email that works<br /><em>at your speed.</em></h1>
<p>A calm, intelligent inbox where you and your AI agents can send, receive, organize, and get work done.</p>
<div className="hero-actions"><a className="button button-coral" href="/signup">Get your @agmail <ArrowRight size={17} /></a><a className="button button-outline" href="#product">See how it works</a></div>
<div className="trust-line"><span><Check size={13} /> Free to start</span><span><Check size={13} /> No card required</span></div>
</div>
<div className="product-stage" aria-label="Agmail inbox preview">
<div className="orb orb-one" /><div className="orb orb-two" />
<div className="inbox-window">
<aside className="inbox-sidebar">
<div className="mini-brand"><Brand compact /></div>
<button className="compose"><span>+</span></button>
<div className="side-icons"><span className="active"><Inbox size={18} /></span><span><Send size={18} /></span><span><Clock3 size={18} /></span></div>
<div className="avatar">AK</div>
</aside>
<div className="mail-panel">
<header className="mail-toolbar"><div><small>Good morning,</small><strong>Alex</strong></div><div className="toolbar-actions"><Search size={17} /><Menu size={17} /></div></header>
<div className="inbox-heading"><div><h3>Inbox</h3><span>12</span></div><button>Priority <ChevronDown size={13} /></button></div>
<div className="agent-summary"><div className="agent-icon"><Bot size={19} /></div><div><strong>Your agent handled 8 emails</strong><p>3 replies drafted · 2 tasks completed</p></div><ArrowRight size={16} /></div>
<div className="mail-list">
{mail.map((item, index) => (
<article className={index === 0 ? "mail-item selected" : "mail-item"} key={item.sender}>
<span className={`sender-avatar ${item.color}`}>{item.sender[0]}</span><div><strong>{item.sender}</strong><p>{item.subject}</p></div><time>{item.time}</time>
</article>
))}
</div>
<div className="quick-reply"><Paperclip size={16} /><span>Ask your agent to draft a reply…</span><button><ArrowRight size={15} /></button></div>
</div>
</div>
<div className="floating-note"><span><Check size={14} /></span><div><strong>Follow-up sent</strong><small>Handled by your agent</small></div></div>
</div>
</section>
<section className="feature-strip shell" id="product">
<div className="feature-intro"><span>One inbox. Less noise.</span><h2>Made for the way work is changing.</h2></div>
<div className="feature-grid">
<article><span className="feature-icon peach"><Bot size={21} /></span><h3>Agent-ready</h3><p>Give every agent a secure address and clear permissions.</p></article>
<article><span className="feature-icon cyan"><Sparkles size={21} /></span><h3>Quietly intelligent</h3><p>Summaries, sorting, and replies—without the clutter.</p></article>
<article id="security"><span className="feature-icon violet"><ShieldCheck size={21} /></span><h3>Private by design</h3><p>Your conversations stay encrypted and in your control.</p></article>
</div>
</section>
</main>
);
}