email box empty styling
This commit is contained in:
parent
1ee8a58117
commit
5cb49f4cb1
19 changed files with 547 additions and 67 deletions
|
|
@ -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 = ""
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
44
backend/app/services/welcome_email.py
Normal file
44
backend/app/services/welcome_email.py
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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:-}
|
||||||
|
|
|
||||||
|
|
@ -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'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'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're all caught up.
|
<Check size={14} strokeWidth={2} /> You'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'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's agenda</h3>
|
<h3>Today'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>
|
||||||
|
|
|
||||||
|
|
@ -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) && (
|
||||||
|
|
|
||||||
18
frontend/src/components/MailboxEmptyIllustration.jsx
Normal file
18
frontend/src/components/MailboxEmptyIllustration.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
frontend/src/components/MailboxEmptyState.jsx
Normal file
63
frontend/src/components/MailboxEmptyState.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
26
frontend/src/hooks/useMailboxMaturity.js
Normal file
26
frontend/src/hooks/useMailboxMaturity.js
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
99
frontend/src/utils/mailboxEmptyCopy.js
Normal file
99
frontend/src/utils/mailboxEmptyCopy.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
2
smtp/outbound_header_checks
Normal file
2
smtp/outbound_header_checks
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue