email box empty styling

This commit is contained in:
debu-2 2026-10-05 21:34:12 +05:30
parent 1ee8a58117
commit 5cb49f4cb1
19 changed files with 547 additions and 67 deletions

View file

@ -29,6 +29,7 @@ class Settings(BaseSettings):
# Outbound via self-hosted Postfix (Docker service `smtp` by default) # Outbound via self-hosted Postfix (Docker service `smtp` by default)
smtp_host: str = "smtp" smtp_host: str = "smtp"
smtp_port: int = 25 smtp_port: int = 25
smtp_client_hostname: str = "backend.gentic.email"
smtp_public_host: str = "mail.gentic.email" smtp_public_host: str = "mail.gentic.email"
smtp_username: str = "" smtp_username: str = ""
smtp_password: str = "" smtp_password: str = ""

View file

@ -1,3 +1,4 @@
import logging
import re import re
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from uuid import UUID from uuid import UUID
@ -24,6 +25,9 @@ from app.schemas import (
PasswordChangeRequest, PasswordChangeRequest,
) )
from app.services.member_invitations import get_valid_invitation from app.services.member_invitations import get_valid_invitation
from app.services.welcome_email import send_welcome_email
logger = logging.getLogger(__name__)
from app.services.domain_policy import assert_company_domain_allowed from app.services.domain_policy import assert_company_domain_allowed
from app.services.domain_provisioning import account_is_domain_owner from app.services.domain_provisioning import account_is_domain_owner
from app.services.domain_provisioning import ( from app.services.domain_provisioning import (
@ -292,6 +296,10 @@ async def accept_invite(
invitation.used_at = datetime.now(timezone.utc) invitation.used_at = datetime.now(timezone.utc)
await session.commit() await session.commit()
await session.refresh(account) await session.refresh(account)
try:
await send_welcome_email(session, account)
except Exception as error:
logger.warning("Welcome email could not be sent: %s", error)
return AuthResponse( return AuthResponse(
access_token=create_access_token(account), access_token=create_access_token(account),
account=await account_read(session, account), account=await account_read(session, account),

View file

@ -159,3 +159,72 @@ def build_employee_invitation_email(
</html>""" </html>"""
return subject, body_text, body_html return subject, body_text, body_html
def build_welcome_email(
*,
first_name: str,
work_email: str,
organization: str | None,
login_url: str,
is_company_account: bool,
) -> tuple[str, str, str]:
name = first_name.strip() or "there"
subject = "Welcome to AGMail"
org_line = (
f"Your {work_email} mailbox is ready."
if is_company_account
else f"Your {work_email} address is active."
)
admin_note = (
"Your organization administrator manages this account."
if is_company_account and organization
else ""
)
body_text = (
f"Hello {name},\n\n"
f"Welcome to {BRAND_NAME}. {org_line}\n\n"
f"What you can do now:\n"
f"- Compose your first email from the inbox\n"
f"- Ask AG Agent to summarize threads and surface action items\n"
f"- Review account and security settings\n\n"
f"Sign in: {login_url}\n\n"
f"{admin_note}\n\n"
f"—\n"
f"AGMail Team\n"
f"{BRAND_NAME}"
).strip()
safe_name = _escape(name)
safe_email = _escape(work_email)
safe_login = _escape(login_url)
safe_org = _escape(organization or "")
safe_admin = _escape(admin_note)
body_html = f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8" /><title>{_escape(subject)}</title></head>
<body style="margin:0;padding:32px 16px;background:#fffdfb;color:#080821;font-family:'DM Sans',sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr><td align="center">
<table role="presentation" width="100%" style="max-width:600px;background:#fff;border:1px solid #eee9e5;border-radius:20px;">
<tr><td style="padding:28px 32px;">
<p style="margin:0 0 12px;font-size:12px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:#ff6a52;">Welcome</p>
<h1 style="margin:0 0 16px;font-size:24px;">Welcome to AGMail</h1>
<p style="margin:0 0 16px;font-size:15px;line-height:1.7;color:#67677a;">Hello {safe_name}, your mailbox <strong>{safe_email}</strong> is ready.</p>
{"<p style='margin:0 0 16px;font-size:14px;color:#67677a;'>Organization: <strong>" + safe_org + "</strong></p>" if organization else ""}
<ul style="margin:0 0 20px;padding-left:20px;color:#67677a;font-size:14px;line-height:1.7;">
<li>Compose email from your inbox</li>
<li>Use AG Agent for summaries and action items</li>
<li>Review security settings after sign-in</li>
</ul>
<a href="{safe_login}" style="display:inline-block;padding:12px 18px;border-radius:10px;background:#080821;color:#fff;text-decoration:none;font-weight:700;">Open AGMail</a>
{"<p style='margin:18px 0 0;font-size:12px;color:#8d8792;'>" + safe_admin + "</p>" if admin_note else ""}
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
return subject, body_text, body_html

View file

@ -76,6 +76,7 @@ async def send_via_smtp(
recipients=recipients, recipients=recipients,
hostname=settings.smtp_host, hostname=settings.smtp_host,
port=settings.smtp_port, port=settings.smtp_port,
local_hostname=settings.smtp_client_hostname,
username=settings.smtp_username or None, username=settings.smtp_username or None,
password=settings.smtp_password or None, password=settings.smtp_password or None,
start_tls=settings.smtp_use_tls, start_tls=settings.smtp_use_tls,

View file

@ -0,0 +1,44 @@
"""Welcome message delivered to a newly activated mailbox."""
from __future__ import annotations
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models import Account
from app.services.domain_provisioning import get_domain_by_name
from app.services.email_templates import BRAND_NAME, build_welcome_email
from app.services.mail_sender import deliver_email
logger = logging.getLogger(__name__)
NOTIFICATIONS_FROM_LOCAL = "notifications"
async def send_welcome_email(session: AsyncSession, account: Account) -> None:
settings = get_settings()
platform_domain = await get_domain_by_name(session, settings.platform_domain)
if platform_domain is None:
raise ValueError("Platform mail domain is not configured.")
login_url = f"https://{settings.app_public_host}/login"
organization = account.company_domain if account.account_type == "company" else None
subject, body_text, body_html = build_welcome_email(
first_name=account.first_name,
work_email=account.email,
organization=organization,
login_url=login_url,
is_company_account=account.account_type == "company",
)
sender = f"AGMail Team <{NOTIFICATIONS_FROM_LOCAL}@{settings.platform_domain}>"
await deliver_email(
sender=sender,
recipients=[account.email],
cc=[],
bcc=[],
subject=subject,
body_text=body_text,
body_html=body_html,
domain=platform_domain,
)

View file

@ -1,4 +1,7 @@
from app.services.email_templates import build_employee_invitation_email from app.services.email_templates import (
build_employee_invitation_email,
build_welcome_email,
)
def test_employee_invitation_uses_token_link_not_password(): def test_employee_invitation_uses_token_link_not_password():
@ -22,3 +25,17 @@ def test_employee_invitation_uses_token_link_not_password():
assert "test-token" in body_html assert "test-token" in body_html
assert "Initial password" not in body_html assert "Initial password" not in body_html
assert "unsubscribe" not in body_html.lower() assert "unsubscribe" not in body_html.lower()
def test_welcome_email_is_useful_not_marketing():
subject, body_text, body_html = build_welcome_email(
first_name="Anurag",
work_email="anurag@road.rentals",
organization="road.rentals",
login_url="https://gentic.email/login",
is_company_account=True,
)
assert subject == "Welcome to AGMail"
assert "AG Agent" in body_text
assert "anurag@road.rentals" in body_html
assert "unsubscribe" not in body_text.lower()

View file

@ -1,7 +1,7 @@
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import AsyncMock, patch
from app.services.mail_sender import build_message, sign_message from app.services.mail_sender import build_message, send_via_smtp, sign_message
def test_dkim_sign_uses_selector_before_domain(): def test_dkim_sign_uses_selector_before_domain():
@ -27,3 +27,17 @@ def test_dkim_sign_uses_selector_before_domain():
assert signer.call_args.args[1] == b"agmail" assert signer.call_args.args[1] == b"agmail"
assert signer.call_args.args[2] == b"ritoesis.com" assert signer.call_args.args[2] == b"ritoesis.com"
async def test_send_via_smtp_uses_configured_client_hostname():
with patch(
"app.services.mail_sender.aiosmtplib.send",
new_callable=AsyncMock,
) as send_mock:
await send_via_smtp(
b"raw",
sender="noreply@gentic.email",
recipients=["user@example.com"],
)
assert send_mock.await_args.kwargs["local_hostname"] == "backend.gentic.email"

View file

@ -55,6 +55,7 @@ services:
build: build:
context: ../../backend context: ../../backend
container_name: agmail-backend container_name: agmail-backend
hostname: ${SMTP_CLIENT_HOSTNAME:-backend.gentic.email}
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-agmail}:${POSTGRES_PASSWORD:-agmail_dev_password}@postgres:5432/${POSTGRES_DB:-agmail_profiles} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-agmail}:${POSTGRES_PASSWORD:-agmail_dev_password}@postgres:5432/${POSTGRES_DB:-agmail_profiles}
@ -67,6 +68,7 @@ services:
DKIM_SELECTOR: ${DKIM_SELECTOR:-agmail} DKIM_SELECTOR: ${DKIM_SELECTOR:-agmail}
SMTP_HOST: ${SMTP_HOST:-smtp} SMTP_HOST: ${SMTP_HOST:-smtp}
SMTP_PORT: ${SMTP_PORT:-25} SMTP_PORT: ${SMTP_PORT:-25}
SMTP_CLIENT_HOSTNAME: ${SMTP_CLIENT_HOSTNAME:-backend.gentic.email}
SMTP_PUBLIC_HOST: ${SMTP_PUBLIC_HOST:-mail.gentic.email} SMTP_PUBLIC_HOST: ${SMTP_PUBLIC_HOST:-mail.gentic.email}
SMTP_USERNAME: ${SMTP_USERNAME:-} SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-}

View file

@ -5,6 +5,7 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
ClipboardList, ClipboardList,
CircleHelp,
FileSearch, FileSearch,
FileText, FileText,
ListChecks, ListChecks,
@ -12,9 +13,11 @@ import {
Paperclip, Paperclip,
PenLine, PenLine,
Send, Send,
Settings,
Sparkles, Sparkles,
} from "lucide-react"; } from "lucide-react";
import AgentInsight from "./AgentInsight"; import AgentInsight from "./AgentInsight";
import AgentSpark from "./AgentSpark";
import SenderAvatar from "./SenderAvatar"; import SenderAvatar from "./SenderAvatar";
import { inboxContext } from "../utils/inboxSignals"; import { inboxContext } from "../utils/inboxSignals";
@ -57,7 +60,7 @@ const cards = [
function greeting(name) { function greeting(name) {
const hour = new Date().getHours(); const hour = new Date().getHours();
const period = hour < 12 ? "Good morning" : hour < 17 ? "Good afternoon" : "Good evening"; const period = hour < 12 ? "Good morning" : hour < 17 ? "Good afternoon" : "Good evening";
return `${period}, ${name}!`; return `${period}, ${name}`;
} }
function statCaption(card, stats) { function statCaption(card, stats) {
@ -79,6 +82,9 @@ export default function AgAgentPanel({
focus, focus,
insight, insight,
loading = false, loading = false,
mailboxEmpty = false,
isBrandNewMailbox = false,
onCompose,
onQuickAction, onQuickAction,
onOpenEmail, onOpenEmail,
onAgentAsk, onAgentAsk,
@ -89,6 +95,9 @@ export default function AgAgentPanel({
const firstName = account?.first_name || "there"; const firstName = account?.first_name || "there";
const allMetricsZero = metricTotal(stats) === 0; const allMetricsZero = metricTotal(stats) === 0;
const hasQuery = agentQuery.trim().length > 0; const hasQuery = agentQuery.trim().length > 0;
const showOnboarding = !insight && !loading && mailboxEmpty && isBrandNewMailbox;
const showQuietEmpty = !insight && !loading && mailboxEmpty && !isBrandNewMailbox;
const showFullPanel = !insight && !loading && !mailboxEmpty;
function submitAgentQuery(event) { function submitAgentQuery(event) {
event.preventDefault(); event.preventDefault();
@ -126,7 +135,13 @@ export default function AgAgentPanel({
{!insight && ( {!insight && (
<div className="agent-panel-hello"> <div className="agent-panel-hello">
<strong>{greeting(firstName)}</strong> <strong>{greeting(firstName)}</strong>
<span>Here&apos;s what needs your attention today.</span> <span>
{showOnboarding
? "Your new inbox is ready."
: showQuietEmpty
? "Nothing needs your attention right now."
: "Here's what needs your attention today."}
</span>
</div> </div>
)} )}
@ -137,16 +152,28 @@ export default function AgAgentPanel({
</div> </div>
)} )}
{!insight && !loading && allMetricsZero && ( {showOnboarding && (
<div className="agent-ready-card">
<p className="agent-ready-lead">
<Check size={14} strokeWidth={2} /> AGMail is ready
</p>
<p>
No messages yet. I&apos;ll start organizing and surfacing important
items as email arrives.
</p>
</div>
)}
{showQuietEmpty && (
<div className="agent-caught-up"> <div className="agent-caught-up">
<p className="agent-caught-up-lead"> <p className="agent-caught-up-lead">
<Check size={14} strokeWidth={2} /> You&apos;re all caught up. <Check size={14} strokeWidth={2} /> You&apos;re all caught up.
</p> </p>
<p>No urgent messages or outstanding actions.</p> <p>Your inbox is clear for now.</p>
</div> </div>
)} )}
{!insight && !loading && !allMetricsZero && ( {showFullPanel && !allMetricsZero && (
<div className="agent-stat-grid"> <div className="agent-stat-grid">
{cards.map((card) => { {cards.map((card) => {
const Icon = card.icon; const Icon = card.icon;
@ -173,42 +200,91 @@ export default function AgAgentPanel({
</div> </div>
)} )}
{!insight && focus && !allMetricsZero && <p className="agent-panel-note">{focus}</p>} {showFullPanel && allMetricsZero && (
<div className="agent-caught-up">
<p className="agent-caught-up-lead">
<Check size={14} strokeWidth={2} /> You&apos;re all caught up.
</p>
<p>No urgent messages or outstanding actions.</p>
</div>
)}
{!insight && focus && showFullPanel && !allMetricsZero && (
<p className="agent-panel-note">{focus}</p>
)}
<section className="agent-quick"> <section className="agent-quick">
<h3>Quick actions</h3> <h3>{showOnboarding ? "Quick start" : "Quick actions"}</h3>
<div className="agent-quick-grid"> <div className="agent-quick-grid">
<button type="button" className="agent-quick-btn" onClick={() => onQuickAction("summarize")}> {showOnboarding ? (
<>
<button type="button" className="agent-quick-btn" onClick={() => onQuickAction("learn")}>
<AgentSpark className="agent-quick-icon agent-quick-icon-purple" />
<span>Meet AG Agent</span>
</button>
<button type="button" className="agent-quick-btn" onClick={() => onCompose?.()}>
<PenLine {...ICON_SM} className="agent-quick-icon agent-quick-icon-purple" />
<span>Compose email</span>
</button>
<button type="button" className="agent-quick-btn" onClick={() => onQuickAction("preferences")}>
<Settings {...ICON_SM} className="agent-quick-icon agent-quick-icon-muted" />
<span>Set preferences</span>
</button>
<button type="button" className="agent-quick-btn" onClick={() => onQuickAction("learn")}>
<CircleHelp {...ICON_SM} className="agent-quick-icon agent-quick-icon-slate" />
<span>Learn AGMail</span>
</button>
</>
) : (
<>
<button
type="button"
className="agent-quick-btn"
onClick={() => onQuickAction("summarize")}
disabled={mailboxEmpty}
>
<Sparkles {...ICON_SM} className="agent-quick-icon agent-quick-icon-purple" /> <Sparkles {...ICON_SM} className="agent-quick-icon agent-quick-icon-purple" />
<span>Summarize inbox</span> <span>Summarize inbox</span>
</button> </button>
<button type="button" className="agent-quick-btn" onClick={() => onQuickAction("draft")}> <button
type="button"
className="agent-quick-btn"
onClick={() => onQuickAction("draft")}
disabled={mailboxEmpty}
>
<PenLine {...ICON_SM} className="agent-quick-icon agent-quick-icon-purple" /> <PenLine {...ICON_SM} className="agent-quick-icon agent-quick-icon-purple" />
<span>Draft replies</span> <span>Draft replies</span>
</button> </button>
<button type="button" className="agent-quick-btn" onClick={() => onQuickAction("attachments")}> <button
type="button"
className="agent-quick-btn"
onClick={() => onQuickAction("attachments")}
disabled={mailboxEmpty}
>
<FileSearch {...ICON_SM} className="agent-quick-icon agent-quick-icon-muted" /> <FileSearch {...ICON_SM} className="agent-quick-icon agent-quick-icon-muted" />
<span>Find attachments</span> <span>Find attachments</span>
</button> </button>
<button type="button" className="agent-quick-btn" onClick={() => onQuickAction("actions")}> <button
type="button"
className="agent-quick-btn"
onClick={() => onQuickAction("actions")}
disabled={mailboxEmpty}
>
<ListChecks {...ICON_SM} className="agent-quick-icon agent-quick-icon-slate" /> <ListChecks {...ICON_SM} className="agent-quick-icon agent-quick-icon-slate" />
<span>Show action items</span> <span>Show action items</span>
</button> </button>
</>
)}
</div> </div>
</section> </section>
{!insight && ( {showFullPanel && !insight && (
<section className="agent-context-block agent-agenda"> <section className="agent-context-block agent-agenda">
<div className="agent-context-head"> <div className="agent-context-head">
<h3>Today&apos;s agenda</h3> <h3>Today&apos;s agenda</h3>
<button type="button" className="agent-context-link">View calendar</button> <button type="button" className="agent-context-link">View calendar</button>
</div> </div>
{loading ? ( {today.length > 0 ? (
<div className="agent-loading-rows" aria-hidden="true">
<span className="agent-loading-pulse" />
<span className="agent-loading-pulse short" />
</div>
) : today.length > 0 ? (
<ul className="agent-agenda-list"> <ul className="agent-agenda-list">
{today.map((item) => ( {today.map((item) => (
<li key={`${item.time}-${item.label}`} className="agent-agenda-row"> <li key={`${item.time}-${item.label}`} className="agent-agenda-row">
@ -228,14 +304,10 @@ export default function AgAgentPanel({
</section> </section>
)} )}
{!insight && ( {showFullPanel && !insight && (
<section className="agent-context-block agent-attention-block"> <section className="agent-context-block agent-attention-block">
<h3>Needs attention</h3> <h3>Needs attention</h3>
{loading ? ( {needsReply.length > 0 ? (
<div className="agent-loading-rows" aria-hidden="true">
<span className="agent-loading-pulse" />
</div>
) : needsReply.length > 0 ? (
<ul className="agent-attention-list"> <ul className="agent-attention-list">
{needsReply.slice(0, 3).map((item) => ( {needsReply.slice(0, 3).map((item) => (
<li key={item.id}> <li key={item.id}>
@ -259,7 +331,7 @@ export default function AgAgentPanel({
</section> </section>
)} )}
{!insight && deadlines.length > 0 && ( {showFullPanel && !insight && deadlines.length > 0 && (
<section className="agent-context-block agent-deadlines"> <section className="agent-context-block agent-deadlines">
<h3>Deadlines</h3> <h3>Deadlines</h3>
<ul> <ul>

View file

@ -417,6 +417,7 @@ export default function DashboardPage() {
agentNav={agentNav} agentNav={agentNav}
onLogout={logout} onLogout={logout}
onOpenSettings={() => selectMailItem("Settings")} onOpenSettings={() => selectMailItem("Settings")}
onCompose={() => setComposeOpen(true)}
onStats={handleStats} onStats={handleStats}
/> />
)} )}
@ -428,6 +429,7 @@ export default function DashboardPage() {
account={account} account={account}
onLogout={logout} onLogout={logout}
onOpenSettings={() => selectMailItem("Settings")} onOpenSettings={() => selectMailItem("Settings")}
onCompose={() => setComposeOpen(true)}
/> />
)} )}
{!companyActive && active === "Trash" && ( {!companyActive && active === "Trash" && (
@ -438,6 +440,7 @@ export default function DashboardPage() {
account={account} account={account}
onLogout={logout} onLogout={logout}
onOpenSettings={() => selectMailItem("Settings")} onOpenSettings={() => selectMailItem("Settings")}
onCompose={() => setComposeOpen(true)}
/> />
)} )}
{companyActive === "Overview" && isDomainOwner && !isOrgSetupComplete(account.id) && ( {companyActive === "Overview" && isDomainOwner && !isOrgSetupComplete(account.id) && (

View file

@ -0,0 +1,18 @@
import { Mail } from "lucide-react";
import AgentSpark from "./AgentSpark";
export default function MailboxEmptyIllustration() {
return (
<div className="mailbox-empty-illustration" aria-hidden="true">
<span className="mailbox-empty-spark mailbox-empty-spark-top">
<AgentSpark />
</span>
<span className="mailbox-empty-icon-wrap">
<Mail size={22} strokeWidth={1.75} />
</span>
<span className="mailbox-empty-spark mailbox-empty-spark-bottom">
<AgentSpark />
</span>
</div>
);
}

View file

@ -0,0 +1,63 @@
import { Check } from "lucide-react";
import MailboxEmptyIllustration from "./MailboxEmptyIllustration";
import { getMailboxEmptyCopy } from "../utils/mailboxEmptyCopy";
export default function MailboxEmptyState({
mailbox,
title,
category,
isBrandNew,
hasFilters,
hasSearch,
lens,
onCompose,
onRefresh,
}) {
const copy = getMailboxEmptyCopy({
mailbox,
title,
category,
isBrandNew,
hasFilters,
hasSearch,
lens,
});
function handlePrimary() {
if (copy.primaryCta?.action === "compose") onCompose?.();
}
function handleSecondary() {
if (copy.secondaryCta?.action === "refresh") onRefresh?.();
}
return (
<div className="mailbox-empty-body">
<div className="mailbox-empty-content">
<MailboxEmptyIllustration />
<h3>{copy.heading}</h3>
<p>{copy.description}</p>
{(copy.primaryCta || copy.secondaryCta) && (
<div className="mailbox-empty-actions">
{copy.primaryCta && (
<button type="button" className="mailbox-empty-cta" onClick={handlePrimary}>
{copy.primaryCta.label}
</button>
)}
{copy.secondaryCta && (
<button type="button" className="mailbox-empty-link" onClick={handleSecondary}>
{copy.secondaryCta.label}
</button>
)}
</div>
)}
{copy.showActiveBadge && (
<p className="mailbox-empty-badge">
<Check size={14} strokeWidth={2} />
Email address is active
</p>
)}
</div>
</div>
);
}

View file

@ -5,7 +5,6 @@ import {
ChevronRight, ChevronRight,
CircleHelp, CircleHelp,
Filter, Filter,
Inbox,
LayoutGrid, LayoutGrid,
List, List,
Mail, Mail,
@ -31,8 +30,10 @@ import AgAgentPanel from "./AgAgentPanel";
import AgentSpark from "./AgentSpark"; import AgentSpark from "./AgentSpark";
import CommandPalette from "./CommandPalette"; import CommandPalette from "./CommandPalette";
import { useAgentInsight } from "../hooks/useAgentInsight"; import { useAgentInsight } from "../hooks/useAgentInsight";
import { useMailboxMaturity } from "../hooks/useMailboxMaturity";
import MailDetail from "./MailDetail"; import MailDetail from "./MailDetail";
import MailRow from "./MailRow"; import MailRow from "./MailRow";
import MailboxEmptyState from "./MailboxEmptyState";
import MailboxSelectionBar from "./MailboxSelectionBar"; import MailboxSelectionBar from "./MailboxSelectionBar";
import StickyNotesPanel from "./StickyNotesPanel"; import StickyNotesPanel from "./StickyNotesPanel";
import { import {
@ -70,6 +71,7 @@ export default function MailboxView({
agentNav = null, agentNav = null,
onLogout, onLogout,
onOpenSettings, onOpenSettings,
onCompose,
onStats, onStats,
}) { }) {
const token = sessionStorage.getItem("agmail_access_token"); const token = sessionStorage.getItem("agmail_access_token");
@ -165,6 +167,11 @@ export default function MailboxView({
[checked], [checked],
); );
const selectedCount = selectedIds.length; const selectedCount = selectedIds.length;
const hasFilters = filters.read !== "all" || Boolean(filters.sender.trim())
|| Boolean(filters.dateFrom) || Boolean(filters.dateTo);
const hasSearch = Boolean(filters.query.trim());
const { isBrandNew } = useMailboxMaturity(account, emails, status.loading);
const isEmpty = !status.loading && !status.error && listedEmails.length === 0;
useEffect(() => { useEffect(() => {
if (!onStats) return; if (!onStats) return;
@ -495,8 +502,6 @@ export default function MailboxView({
setDetail(null); setDetail(null);
} }
const hasFilters =
filters.read !== "all" || filters.sender || filters.dateFrom || filters.dateTo;
const initials = `${account?.first_name?.[0] || ""}${account?.last_name?.[0] || ""}`.toUpperCase(); const initials = `${account?.first_name?.[0] || ""}${account?.last_name?.[0] || ""}`.toUpperCase();
const rangeStart = listedEmails.length ? cursorHistory.length * PAGE_SIZE + 1 : 0; const rangeStart = listedEmails.length ? cursorHistory.length * PAGE_SIZE + 1 : 0;
const rangeEnd = cursorHistory.length * PAGE_SIZE + listedEmails.length; const rangeEnd = cursorHistory.length * PAGE_SIZE + listedEmails.length;
@ -623,19 +628,25 @@ export default function MailboxView({
</div> </div>
)} )}
<div className="mailbox-rows" aria-busy={status.loading}> <div className={`mailbox-rows${isEmpty ? " is-empty" : ""}`} aria-busy={status.loading}>
{status.loading && <div className="mailbox-state">Loading messages…</div>} {status.loading && <div className="mailbox-state loading">Loading messages…</div>}
{status.error && ( {status.error && (
<div className="mailbox-state error" role="alert"> <div className="mailbox-state error" role="alert">
<p>{status.error}</p><button type="button" onClick={load}>Try again</button> <p>{status.error}</p><button type="button" onClick={load}>Try again</button>
</div> </div>
)} )}
{!status.loading && !status.error && listedEmails.length === 0 && ( {isEmpty && (
<div className="mailbox-state empty"> <MailboxEmptyState
<span><Inbox size={22} /></span> mailbox={mailbox}
<strong>No messages here</strong> title={title}
<p>{hasFilters || filters.query || lens !== "all" ? "Try another category, label, or search." : `Your ${title.toLowerCase()} is empty.`}</p> category={mailbox === "inbox" ? category : null}
</div> isBrandNew={isBrandNew}
hasFilters={hasFilters}
hasSearch={hasSearch}
lens={lens}
onCompose={onCompose}
onRefresh={load}
/>
)} )}
{!status.loading && !status.error && listedEmails.map((email) => ( {!status.loading && !status.error && listedEmails.map((email) => (
<MailRow <MailRow
@ -655,19 +666,24 @@ export default function MailboxView({
))} ))}
</div> </div>
{!status.loading && listedEmails.length > 0 && ( {!status.loading && (
<footer className="mailbox-pagination"> <footer className="mailbox-pagination">
<span className="mailbox-pagination-range"> <span className="mailbox-pagination-range">
{`${rangeStart}–${rangeEnd}`} {listedEmails.length === 0
{nextCursor ? "" : ` · ${rangeEnd} loaded`} ? "0 messages"
: `${rangeStart}–${rangeEnd}${nextCursor ? "" : ` · ${rangeEnd} loaded`}`}
</span> </span>
<div className="mailbox-pagination-controls"> <div className="mailbox-pagination-controls">
{listedEmails.length > 0 && (
<>
<button type="button" title="Previous page" aria-label="Previous page" disabled={!cursorHistory.length} onClick={previousPage}> <button type="button" title="Previous page" aria-label="Previous page" disabled={!cursorHistory.length} onClick={previousPage}>
<ChevronLeft size={16} strokeWidth={1.75} /> <ChevronLeft size={16} strokeWidth={1.75} />
</button> </button>
<button type="button" title="Next page" aria-label="Next page" disabled={!nextCursor} onClick={nextPage}> <button type="button" title="Next page" aria-label="Next page" disabled={!nextCursor} onClick={nextPage}>
<ChevronRight size={16} strokeWidth={1.75} /> <ChevronRight size={16} strokeWidth={1.75} />
</button> </button>
</>
)}
</div> </div>
<div className="mailbox-pagination-density" aria-label="List density"> <div className="mailbox-pagination-density" aria-label="List density">
<button type="button" className="active" title="Comfortable density" aria-label="Comfortable density"><List size={15} strokeWidth={1.75} /></button> <button type="button" className="active" title="Comfortable density" aria-label="Comfortable density"><List size={15} strokeWidth={1.75} /></button>
@ -702,6 +718,9 @@ export default function MailboxView({
focus={agentFocus} focus={agentFocus}
insight={insight} insight={insight}
loading={status.loading} loading={status.loading}
mailboxEmpty={isEmpty}
isBrandNewMailbox={isBrandNew && mailbox === "inbox"}
onCompose={onCompose}
onQuickAction={runQuickAction} onQuickAction={runQuickAction}
onOpenEmail={(emailId) => { onOpenEmail={(emailId) => {
const email = listedEmails.find((item) => item.id === emailId); const email = listedEmails.find((item) => item.id === emailId);

View file

@ -0,0 +1,26 @@
import { useEffect, useState } from "react";
function storageKey(accountId) {
return `agmail_had_mail_${accountId}`;
}
export function useMailboxMaturity(account, emails, loading) {
const [hadMail, setHadMail] = useState(() => {
if (!account?.id) return false;
return localStorage.getItem(storageKey(account.id)) === "1";
});
useEffect(() => {
if (!account?.id) return;
if (!loading && emails.length > 0) {
localStorage.setItem(storageKey(account.id), "1");
setHadMail(true);
}
}, [account?.id, emails.length, loading]);
const isBrandNew = Boolean(
account?.id && !loading && emails.length === 0 && !hadMail,
);
return { isBrandNew, hadMail };
}

View file

@ -558,12 +558,30 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.mailbox-list-footer { margin-top:auto; padding:18px 20px 20px; display:flex; align-items:center; justify-content:space-between; gap:16px; border-top:1px solid #ebe7e3; background:linear-gradient(180deg,#fcfbfa 0%,#f8f7f5 100%); color:#8f8a94; font-size:11px; } .mailbox-list-footer { margin-top:auto; padding:18px 20px 20px; display:flex; align-items:center; justify-content:space-between; gap:16px; border-top:1px solid #ebe7e3; background:linear-gradient(180deg,#fcfbfa 0%,#f8f7f5 100%); color:#8f8a94; font-size:11px; }
.mailbox-list-footer p { margin:0; color:#5c5763; font-weight:600; } .mailbox-list-footer p { margin:0; color:#5c5763; font-weight:600; }
.mailbox-list-footer span { color:#a09aa4; font-size:10px; } .mailbox-list-footer span { color:#a09aa4; font-size:10px; }
.mailbox-state { min-height:220px; padding:30px; display:flex; flex-direction:column; align-items:center; justify-content:center; color:#8d8891; text-align:center; font-size:11px; background:#ebe8e3; } .mailbox-state { min-height:120px; padding:30px; display:flex; flex-direction:column; align-items:center; justify-content:center; color:#8d8891; text-align:center; font-size:11px; background:var(--surface); }
.mailbox-state.empty span { width:46px; height:46px; margin-bottom:13px; display:grid; place-items:center; border-radius:14px; color:var(--coral); background:#fff0eb; } .mailbox-state.loading { min-height:300px; }
.mailbox-state.empty strong { color:#393541; font:600 15px "Manrope"; }
.mailbox-state p { margin:7px 0; } .mailbox-state p { margin:7px 0; }
.mailbox-state.error { color:#a94f40; } .mailbox-state.error { color:#a94f40; }
.mailbox-state button { margin-top:8px; padding:7px 12px; border:0; border-radius:8px; background:var(--ink); color:white; font-size:10px; } .mailbox-state button { margin-top:8px; padding:7px 12px; border:0; border-radius:8px; background:var(--ink); color:white; font-size:10px; }
.mailbox-empty-body { min-height:300px; padding:28px 20px; display:flex; align-items:center; justify-content:center; background:#FCFBFA; border-bottom:1px solid var(--border-subtle); }
.mailbox-empty-content { width:min(100%,360px); display:flex; flex-direction:column; align-items:center; text-align:center; }
.mailbox-empty-illustration { position:relative; width:60px; height:60px; margin-bottom:15px; }
.mailbox-empty-icon-wrap { width:52px; height:52px; position:absolute; left:4px; top:4px; display:grid; place-items:center; border-radius:14px; color:var(--coral); background:#FFF1ED; box-shadow:0 1px 2px rgba(20,20,30,0.04); }
.mailbox-empty-spark { position:absolute; color:var(--agent); font-size:11px; line-height:1; }
.mailbox-empty-spark-top { top:0; left:50%; transform:translateX(-50%); }
.mailbox-empty-spark-bottom { right:2px; bottom:2px; font-size:9px; opacity:.85; }
.mailbox-empty-content h3 { margin:0; color:#2a2530; font:650 16px/1.35 "Manrope"; letter-spacing:-.3px; }
.mailbox-empty-content p { margin:6px 0 0; color:#8d8792; font:400 12px/1.55 "DM Sans"; }
.mailbox-empty-actions { margin-top:15px; display:flex; flex-direction:column; align-items:center; gap:10px; }
.mailbox-empty-cta { height:36px; padding:0 14px; border:0; border-radius:8px; background:linear-gradient(180deg,#ff806d 0%,#ff5c55 100%); color:#fff; font:600 12px "DM Sans"; box-shadow:0 6px 16px rgba(255,92,85,0.22); cursor:pointer; }
.mailbox-empty-cta:hover { filter:brightness(1.03); }
.mailbox-empty-link { border:0; background:transparent; color:#7b67c8; font:600 12px "DM Sans"; cursor:pointer; }
.mailbox-empty-link:hover { color:#5a3fe0; }
.mailbox-empty-badge { margin:14px 0 0; display:inline-flex; align-items:center; gap:6px; color:#3d8a5d; font:500 11px "DM Sans"; }
.agent-ready-card { margin:0 0 12px; padding:12px 13px; border:1px solid rgba(118,86,232,0.12); border-radius:12px; background:#faf8ff; }
.agent-ready-card p { margin:0; color:#77758A; font:400 10px/15px "DM Sans"; }
.agent-ready-lead { margin:0 0 6px !important; display:flex; align-items:center; gap:6px; color:#5a3fe0 !important; font:600 11px/14px "DM Sans" !important; }
.agent-quick-btn:disabled { opacity:.45; cursor:not-allowed; }
.mailbox-pagination { min-height:44px; padding:var(--space-2) var(--space-4); display:grid; grid-template-columns:1fr auto auto; align-items:center; gap:var(--space-3); border-top:1px solid var(--border-subtle); background:var(--surface); color:#8d8792; font-size:var(--text-micro); } .mailbox-pagination { min-height:44px; padding:var(--space-2) var(--space-4); display:grid; grid-template-columns:1fr auto auto; align-items:center; gap:var(--space-3); border-top:1px solid var(--border-subtle); background:var(--surface); color:#8d8792; font-size:var(--text-micro); }
.mailbox-pagination-range { font-weight:500; } .mailbox-pagination-range { font-weight:500; }
.mailbox-pagination-controls,.mailbox-pagination-density { display:flex; align-items:center; gap:var(--space-1); } .mailbox-pagination-controls,.mailbox-pagination-density { display:flex; align-items:center; gap:var(--space-1); }
@ -839,7 +857,7 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.dashboard-content { padding:var(--space-3) var(--space-4) var(--space-4); background:var(--canvas); } .dashboard-content { padding:var(--space-3) var(--space-4) var(--space-4); background:var(--canvas); }
.dashboard-content-heading h1 { font-size:clamp(28px,3vw,36px); letter-spacing:-1.4px; } .dashboard-content-heading h1 { font-size:clamp(28px,3vw,36px); letter-spacing:-1.4px; }
.mailbox-workspace { display:flex; flex-direction:column; gap:var(--space-3); } .mailbox-workspace { display:flex; flex-direction:column; gap:var(--space-3); }
.mailbox-main-column { min-width:0; align-self:start; } .mailbox-main-column { width:100%; min-width:0; align-self:stretch; }
.workspace-header { min-height:56px; padding:var(--space-2) var(--space-3); display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:0 var(--space-4); align-items:center; border-radius:var(--radius-lg); background:var(--workspace); } .workspace-header { min-height:56px; padding:var(--space-2) var(--space-3); display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:0 var(--space-4); align-items:center; border-radius:var(--radius-lg); background:var(--workspace); }
.workspace-zone { display:flex; align-items:center; min-width:0; } .workspace-zone { display:flex; align-items:center; min-width:0; }
.workspace-zone-command { padding-right:8px; } .workspace-zone-command { padding-right:8px; }
@ -884,8 +902,8 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.workspace-account strong { color:#241f2b; font-size:12px; font-weight:600; } .workspace-account strong { color:#241f2b; font-size:12px; font-weight:600; }
.workspace-account small { color:#7a7580; font-size:10px; max-width:160px; } .workspace-account small { color:#7a7580; font-size:10px; max-width:160px; }
.workspace-account>span { width:32px; height:32px; font-size:11px; } .workspace-account>span { width:32px; height:32px; font-size:11px; }
.mailbox-stage { display:grid; grid-template-columns:minmax(0,1fr) minmax(300px,320px); gap:var(--space-4); align-items:start; } .mailbox-stage { display:grid; grid-template-columns:minmax(0,1fr) 320px; gap:16px; align-items:start; }
.mailbox-shell { position:relative; align-self:start; overflow:hidden; border:1px solid var(--border-subtle); border-radius:var(--radius-xl); background:var(--surface); box-shadow:var(--shadow-surface); } .mailbox-shell { position:relative; width:100%; align-self:stretch; overflow:hidden; border:1px solid var(--border-subtle); border-radius:var(--radius-xl); background:var(--surface); box-shadow:var(--shadow-surface); }
.mailbox-list-pane { display:flex; flex-direction:column; background:var(--surface); border-radius:var(--radius-xl); } .mailbox-list-pane { display:flex; flex-direction:column; background:var(--surface); border-radius:var(--radius-xl); }
.mailbox-categories { min-height:44px; padding:0 var(--space-2); display:flex; align-items:flex-end; gap:var(--space-1); border-bottom:1px solid var(--border-subtle); background:var(--surface); overflow:auto; } .mailbox-categories { min-height:44px; padding:0 var(--space-2); display:flex; align-items:flex-end; gap:var(--space-1); border-bottom:1px solid var(--border-subtle); background:var(--surface); overflow:auto; }
.mailbox-categories button { height:44px; padding:0 var(--space-3) var(--space-2); position:relative; display:inline-flex; align-items:center; gap:var(--space-2); border:0; border-radius:0; background:transparent; color:#7a7580; font-size:var(--text-meta); font-weight:500; white-space:nowrap; transition:background var(--motion-fast) var(--ease-standard),color var(--motion-fast) var(--ease-standard); } .mailbox-categories button { height:44px; padding:0 var(--space-3) var(--space-2); position:relative; display:inline-flex; align-items:center; gap:var(--space-2); border:0; border-radius:0; background:transparent; color:#7a7580; font-size:var(--text-meta); font-weight:500; white-space:nowrap; transition:background var(--motion-fast) var(--ease-standard),color var(--motion-fast) var(--ease-standard); }
@ -904,6 +922,7 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.mailbox-selection-actions button:hover { background:#ebe8e3; color:#241f2b; } .mailbox-selection-actions button:hover { background:#ebe8e3; color:#241f2b; }
.mailbox-selection-summarize { height:36px; padding:0 var(--space-3); white-space:nowrap; font-size:var(--text-meta); } .mailbox-selection-summarize { height:36px; padding:0 var(--space-3); white-space:nowrap; font-size:var(--text-meta); }
.mailbox-rows { flex:0 1 auto; max-height:calc(100vh - 260px); overflow-y:auto; background:var(--surface); } .mailbox-rows { flex:0 1 auto; max-height:calc(100vh - 260px); overflow-y:auto; background:var(--surface); }
.mailbox-rows.is-empty { max-height:none; overflow:visible; }
.mailbox-row { width:100%; height:46px; min-height:46px; max-height:46px; padding:0 var(--space-3); display:grid; grid-template-columns:28px 28px 38px minmax(150px,180px) minmax(0,1fr) minmax(72px,108px) 28px minmax(148px,168px); align-items:center; gap:var(--space-2); position:relative; border:0; border-bottom:1px solid rgba(20,20,30,0.055); background:var(--surface); color:#4a4652; text-align:left; cursor:pointer; overflow:hidden; transition:background var(--motion-fast) var(--ease-standard); } .mailbox-row { width:100%; height:46px; min-height:46px; max-height:46px; padding:0 var(--space-3); display:grid; grid-template-columns:28px 28px 38px minmax(150px,180px) minmax(0,1fr) minmax(72px,108px) 28px minmax(148px,168px); align-items:center; gap:var(--space-2); position:relative; border:0; border-bottom:1px solid rgba(20,20,30,0.055); background:var(--surface); color:#4a4652; text-align:left; cursor:pointer; overflow:hidden; transition:background var(--motion-fast) var(--ease-standard); }
.mailbox-row.unread { background:#fffcfa; } .mailbox-row.unread { background:#fffcfa; }
.mailbox-row:not(.unread) .mailbox-sender { color:#5a5562; font-weight:500; } .mailbox-row:not(.unread) .mailbox-sender { color:#5a5562; font-weight:500; }

View file

@ -0,0 +1,99 @@
export function getMailboxEmptyCopy({
mailbox,
title,
category,
isBrandNew,
hasFilters,
hasSearch,
lens,
}) {
if (hasSearch) {
return {
heading: "No emails found",
description: "Try changing your search or filters.",
primaryCta: null,
secondaryCta: null,
showActiveBadge: false,
};
}
if (hasFilters || lens !== "all") {
return {
heading: "No messages here",
description: "Try another category, label, or filter.",
primaryCta: null,
secondaryCta: { label: "Refresh inbox", action: "refresh" },
showActiveBadge: false,
};
}
if (mailbox === "inbox" && category && category !== "All Mail" && category !== "Primary") {
const categoryHeadings = {
Updates: "No updates here",
Promotions: "No promotions here",
Social: "No social messages here",
Finance: "No finance emails here",
};
const categoryDescriptions = {
Updates: "Emails categorized as updates will appear here.",
Promotions: "Emails categorized as promotions will appear here.",
Social: "Emails categorized as social will appear here.",
Finance: "Emails categorized as finance will appear here.",
};
return {
heading: categoryHeadings[category] || `No ${category.toLowerCase()} here`,
description: categoryDescriptions[category] || `Emails in ${category} will appear here.`,
primaryCta: null,
secondaryCta: { label: "Refresh inbox", action: "refresh" },
showActiveBadge: false,
};
}
if (mailbox === "sent") {
return {
heading: "Nothing sent yet",
description: "Emails you send will appear here.",
primaryCta: { label: "Compose email", action: "compose" },
secondaryCta: null,
showActiveBadge: false,
};
}
if (mailbox === "trash") {
return {
heading: "Trash is empty",
description: "Deleted messages will appear here.",
primaryCta: null,
secondaryCta: { label: "Refresh", action: "refresh" },
showActiveBadge: false,
};
}
if (mailbox === "inbox" && isBrandNew) {
return {
heading: "Welcome to AGMail",
description: "Your inbox is ready. New conversations will appear here.",
primaryCta: { label: "Compose your first email", action: "compose" },
secondaryCta: null,
showActiveBadge: true,
};
}
if (mailbox === "inbox") {
return {
heading: "Your inbox is all clear",
description: "New emails will appear here when they arrive.",
primaryCta: { label: "Compose email", action: "compose" },
secondaryCta: { label: "Refresh inbox", action: "refresh" },
showActiveBadge: false,
};
}
return {
heading: `No messages in ${title.toLowerCase()}`,
description: `Messages in ${title.toLowerCase()} will appear here.`,
primaryCta: null,
secondaryCta: { label: "Refresh", action: "refresh" },
showActiveBadge: false,
};
}

View file

@ -4,8 +4,10 @@ RUN apk add --no-cache postfix openssl
COPY main.cf /etc/postfix/main.cf COPY main.cf /etc/postfix/main.cf
COPY master.cf /etc/postfix/master.cf COPY master.cf /etc/postfix/master.cf
COPY outbound_header_checks /etc/postfix/outbound_header_checks
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod 0644 /etc/postfix/main.cf /etc/postfix/master.cf && \ RUN chmod 0644 /etc/postfix/main.cf /etc/postfix/master.cf \
/etc/postfix/outbound_header_checks && \
chmod 0755 /entrypoint.sh && \ chmod 0755 /entrypoint.sh && \
mkdir -p /var/spool/postfix/pid mkdir -p /var/spool/postfix/pid

View file

@ -100,6 +100,7 @@ install_relay_maps
postconf -e "myhostname = ${MAIL_HOSTNAME}" postconf -e "myhostname = ${MAIL_HOSTNAME}"
postconf -e "mydomain = ${MAIL_DOMAIN}" postconf -e "mydomain = ${MAIL_DOMAIN}"
postconf -e "myorigin = \$mydomain" postconf -e "myorigin = \$mydomain"
postconf -e "smtp_header_checks = regexp:/etc/postfix/outbound_header_checks"
# STARTTLS cert for inbound providers (Gmail, Zoho, etc.) # STARTTLS cert for inbound providers (Gmail, Zoho, etc.)
mkdir -p /etc/postfix/certs mkdir -p /etc/postfix/certs

View file

@ -0,0 +1,2 @@
# Remove internal Docker/LAN Received hops before internet delivery.
/^Received: from .* \[(10\.|127\.|172\.(1[6-9]|2[0-9]|3[0-9])\.|192\.168\.)/ IGNORE