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)
smtp_host: str = "smtp"
smtp_port: int = 25
smtp_client_hostname: str = "backend.gentic.email"
smtp_public_host: str = "mail.gentic.email"
smtp_username: str = ""
smtp_password: str = ""

View file

@ -1,3 +1,4 @@
import logging
import re
from datetime import date, datetime, timedelta, timezone
from uuid import UUID
@ -24,6 +25,9 @@ from app.schemas import (
PasswordChangeRequest,
)
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_provisioning import account_is_domain_owner
from app.services.domain_provisioning import (
@ -292,6 +296,10 @@ async def accept_invite(
invitation.used_at = datetime.now(timezone.utc)
await session.commit()
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(
access_token=create_access_token(account),
account=await account_read(session, account),

View file

@ -159,3 +159,72 @@ def build_employee_invitation_email(
</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,
hostname=settings.smtp_host,
port=settings.smtp_port,
local_hostname=settings.smtp_client_hostname,
username=settings.smtp_username or None,
password=settings.smtp_password or None,
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():
@ -22,3 +25,17 @@ def test_employee_invitation_uses_token_link_not_password():
assert "test-token" in body_html
assert "Initial password" not in body_html
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 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():
@ -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[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:
context: ../../backend
container_name: agmail-backend
hostname: ${SMTP_CLIENT_HOSTNAME:-backend.gentic.email}
restart: unless-stopped
environment:
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}
SMTP_HOST: ${SMTP_HOST:-smtp}
SMTP_PORT: ${SMTP_PORT:-25}
SMTP_CLIENT_HOSTNAME: ${SMTP_CLIENT_HOSTNAME:-backend.gentic.email}
SMTP_PUBLIC_HOST: ${SMTP_PUBLIC_HOST:-mail.gentic.email}
SMTP_USERNAME: ${SMTP_USERNAME:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}

View file

@ -5,6 +5,7 @@ import {
ChevronDown,
ChevronRight,
ClipboardList,
CircleHelp,
FileSearch,
FileText,
ListChecks,
@ -12,9 +13,11 @@ import {
Paperclip,
PenLine,
Send,
Settings,
Sparkles,
} from "lucide-react";
import AgentInsight from "./AgentInsight";
import AgentSpark from "./AgentSpark";
import SenderAvatar from "./SenderAvatar";
import { inboxContext } from "../utils/inboxSignals";
@ -57,7 +60,7 @@ const cards = [
function greeting(name) {
const hour = new Date().getHours();
const period = hour < 12 ? "Good morning" : hour < 17 ? "Good afternoon" : "Good evening";
return `${period}, ${name}!`;
return `${period}, ${name}`;
}
function statCaption(card, stats) {
@ -79,6 +82,9 @@ export default function AgAgentPanel({
focus,
insight,
loading = false,
mailboxEmpty = false,
isBrandNewMailbox = false,
onCompose,
onQuickAction,
onOpenEmail,
onAgentAsk,
@ -89,6 +95,9 @@ export default function AgAgentPanel({
const firstName = account?.first_name || "there";
const allMetricsZero = metricTotal(stats) === 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) {
event.preventDefault();
@ -126,7 +135,13 @@ export default function AgAgentPanel({
{!insight && (
<div className="agent-panel-hello">
<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>
)}
@ -137,16 +152,28 @@ export default function AgAgentPanel({
</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">
<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>
<p>Your inbox is clear for now.</p>
</div>
)}
{!insight && !loading && !allMetricsZero && (
{showFullPanel && !allMetricsZero && (
<div className="agent-stat-grid">
{cards.map((card) => {
const Icon = card.icon;
@ -173,42 +200,91 @@ export default function AgAgentPanel({
</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">
<h3>Quick actions</h3>
<h3>{showOnboarding ? "Quick start" : "Quick actions"}</h3>
<div className="agent-quick-grid">
<button type="button" className="agent-quick-btn" onClick={() => onQuickAction("summarize")}>
<Sparkles {...ICON_SM} className="agent-quick-icon agent-quick-icon-purple" />
<span>Summarize inbox</span>
</button>
<button type="button" className="agent-quick-btn" onClick={() => onQuickAction("draft")}>
<PenLine {...ICON_SM} className="agent-quick-icon agent-quick-icon-purple" />
<span>Draft replies</span>
</button>
<button type="button" className="agent-quick-btn" onClick={() => onQuickAction("attachments")}>
<FileSearch {...ICON_SM} className="agent-quick-icon agent-quick-icon-muted" />
<span>Find attachments</span>
</button>
<button type="button" className="agent-quick-btn" onClick={() => onQuickAction("actions")}>
<ListChecks {...ICON_SM} className="agent-quick-icon agent-quick-icon-slate" />
<span>Show action items</span>
</button>
{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" />
<span>Summarize inbox</span>
</button>
<button
type="button"
className="agent-quick-btn"
onClick={() => onQuickAction("draft")}
disabled={mailboxEmpty}
>
<PenLine {...ICON_SM} className="agent-quick-icon agent-quick-icon-purple" />
<span>Draft replies</span>
</button>
<button
type="button"
className="agent-quick-btn"
onClick={() => onQuickAction("attachments")}
disabled={mailboxEmpty}
>
<FileSearch {...ICON_SM} className="agent-quick-icon agent-quick-icon-muted" />
<span>Find attachments</span>
</button>
<button
type="button"
className="agent-quick-btn"
onClick={() => onQuickAction("actions")}
disabled={mailboxEmpty}
>
<ListChecks {...ICON_SM} className="agent-quick-icon agent-quick-icon-slate" />
<span>Show action items</span>
</button>
</>
)}
</div>
</section>
{!insight && (
{showFullPanel && !insight && (
<section className="agent-context-block agent-agenda">
<div className="agent-context-head">
<h3>Today&apos;s agenda</h3>
<button type="button" className="agent-context-link">View calendar</button>
</div>
{loading ? (
<div className="agent-loading-rows" aria-hidden="true">
<span className="agent-loading-pulse" />
<span className="agent-loading-pulse short" />
</div>
) : today.length > 0 ? (
{today.length > 0 ? (
<ul className="agent-agenda-list">
{today.map((item) => (
<li key={`${item.time}-${item.label}`} className="agent-agenda-row">
@ -228,14 +304,10 @@ export default function AgAgentPanel({
</section>
)}
{!insight && (
{showFullPanel && !insight && (
<section className="agent-context-block agent-attention-block">
<h3>Needs attention</h3>
{loading ? (
<div className="agent-loading-rows" aria-hidden="true">
<span className="agent-loading-pulse" />
</div>
) : needsReply.length > 0 ? (
{needsReply.length > 0 ? (
<ul className="agent-attention-list">
{needsReply.slice(0, 3).map((item) => (
<li key={item.id}>
@ -259,7 +331,7 @@ export default function AgAgentPanel({
</section>
)}
{!insight && deadlines.length > 0 && (
{showFullPanel && !insight && deadlines.length > 0 && (
<section className="agent-context-block agent-deadlines">
<h3>Deadlines</h3>
<ul>

View file

@ -417,6 +417,7 @@ export default function DashboardPage() {
agentNav={agentNav}
onLogout={logout}
onOpenSettings={() => selectMailItem("Settings")}
onCompose={() => setComposeOpen(true)}
onStats={handleStats}
/>
)}
@ -428,6 +429,7 @@ export default function DashboardPage() {
account={account}
onLogout={logout}
onOpenSettings={() => selectMailItem("Settings")}
onCompose={() => setComposeOpen(true)}
/>
)}
{!companyActive && active === "Trash" && (
@ -438,6 +440,7 @@ export default function DashboardPage() {
account={account}
onLogout={logout}
onOpenSettings={() => selectMailItem("Settings")}
onCompose={() => setComposeOpen(true)}
/>
)}
{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,
CircleHelp,
Filter,
Inbox,
LayoutGrid,
List,
Mail,
@ -31,8 +30,10 @@ import AgAgentPanel from "./AgAgentPanel";
import AgentSpark from "./AgentSpark";
import CommandPalette from "./CommandPalette";
import { useAgentInsight } from "../hooks/useAgentInsight";
import { useMailboxMaturity } from "../hooks/useMailboxMaturity";
import MailDetail from "./MailDetail";
import MailRow from "./MailRow";
import MailboxEmptyState from "./MailboxEmptyState";
import MailboxSelectionBar from "./MailboxSelectionBar";
import StickyNotesPanel from "./StickyNotesPanel";
import {
@ -70,6 +71,7 @@ export default function MailboxView({
agentNav = null,
onLogout,
onOpenSettings,
onCompose,
onStats,
}) {
const token = sessionStorage.getItem("agmail_access_token");
@ -165,6 +167,11 @@ export default function MailboxView({
[checked],
);
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(() => {
if (!onStats) return;
@ -495,8 +502,6 @@ export default function MailboxView({
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 rangeStart = listedEmails.length ? cursorHistory.length * PAGE_SIZE + 1 : 0;
const rangeEnd = cursorHistory.length * PAGE_SIZE + listedEmails.length;
@ -623,19 +628,25 @@ export default function MailboxView({
</div>
)}
<div className="mailbox-rows" aria-busy={status.loading}>
{status.loading && <div className="mailbox-state">Loading messages…</div>}
<div className={`mailbox-rows${isEmpty ? " is-empty" : ""}`} aria-busy={status.loading}>
{status.loading && <div className="mailbox-state loading">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 && listedEmails.length === 0 && (
<div className="mailbox-state empty">
<span><Inbox size={22} /></span>
<strong>No messages here</strong>
<p>{hasFilters || filters.query || lens !== "all" ? "Try another category, label, or search." : `Your ${title.toLowerCase()} is empty.`}</p>
</div>
{isEmpty && (
<MailboxEmptyState
mailbox={mailbox}
title={title}
category={mailbox === "inbox" ? category : null}
isBrandNew={isBrandNew}
hasFilters={hasFilters}
hasSearch={hasSearch}
lens={lens}
onCompose={onCompose}
onRefresh={load}
/>
)}
{!status.loading && !status.error && listedEmails.map((email) => (
<MailRow
@ -655,19 +666,24 @@ export default function MailboxView({
))}
</div>
{!status.loading && listedEmails.length > 0 && (
{!status.loading && (
<footer className="mailbox-pagination">
<span className="mailbox-pagination-range">
{`${rangeStart}–${rangeEnd}`}
{nextCursor ? "" : ` · ${rangeEnd} loaded`}
{listedEmails.length === 0
? "0 messages"
: `${rangeStart}–${rangeEnd}${nextCursor ? "" : ` · ${rangeEnd} loaded`}`}
</span>
<div className="mailbox-pagination-controls">
<button type="button" title="Previous page" aria-label="Previous page" disabled={!cursorHistory.length} onClick={previousPage}>
<ChevronLeft size={16} strokeWidth={1.75} />
</button>
<button type="button" title="Next page" aria-label="Next page" disabled={!nextCursor} onClick={nextPage}>
<ChevronRight size={16} strokeWidth={1.75} />
</button>
{listedEmails.length > 0 && (
<>
<button type="button" title="Previous page" aria-label="Previous page" disabled={!cursorHistory.length} onClick={previousPage}>
<ChevronLeft size={16} strokeWidth={1.75} />
</button>
<button type="button" title="Next page" aria-label="Next page" disabled={!nextCursor} onClick={nextPage}>
<ChevronRight size={16} strokeWidth={1.75} />
</button>
</>
)}
</div>
<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>
@ -702,6 +718,9 @@ export default function MailboxView({
focus={agentFocus}
insight={insight}
loading={status.loading}
mailboxEmpty={isEmpty}
isBrandNewMailbox={isBrandNew && mailbox === "inbox"}
onCompose={onCompose}
onQuickAction={runQuickAction}
onOpenEmail={(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 p { margin:0; color:#5c5763; font-weight:600; }
.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.empty span { width:46px; height:46px; margin-bottom:13px; display:grid; place-items:center; border-radius:14px; color:var(--coral); background:#fff0eb; }
.mailbox-state.empty strong { color:#393541; font:600 15px "Manrope"; }
.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.loading { min-height:300px; }
.mailbox-state p { margin:7px 0; }
.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-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-range { font-weight:500; }
.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-heading h1 { font-size:clamp(28px,3vw,36px); letter-spacing:-1.4px; }
.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-zone { display:flex; align-items:center; min-width:0; }
.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 small { color:#7a7580; font-size:10px; max-width:160px; }
.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-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-stage { display:grid; grid-template-columns:minmax(0,1fr) 320px; gap:16px; align-items:start; }
.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-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); }
@ -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-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.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.unread { background:#fffcfa; }
.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 master.cf /etc/postfix/master.cf
COPY outbound_header_checks /etc/postfix/outbound_header_checks
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 && \
mkdir -p /var/spool/postfix/pid

View file

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