From 5cb49f4cb1581db2b49f3eb60d881c3fbdf902f2 Mon Sep 17 00:00:00 2001 From: debu-2 Date: Mon, 5 Oct 2026 21:34:12 +0530 Subject: [PATCH] email box empty styling --- backend/app/config.py | 1 + backend/app/routers/auth.py | 8 + backend/app/services/email_templates.py | 69 +++++++++ backend/app/services/mail_sender.py | 1 + backend/app/services/welcome_email.py | 44 ++++++ backend/tests/test_email_templates.py | 19 ++- backend/tests/test_mail_sender.py | 18 ++- deploy/app/docker-compose.yml | 2 + frontend/src/components/AgAgentPanel.jsx | 146 +++++++++++++----- frontend/src/components/DashboardPage.jsx | 3 + .../components/MailboxEmptyIllustration.jsx | 18 +++ frontend/src/components/MailboxEmptyState.jsx | 63 ++++++++ frontend/src/components/MailboxView.jsx | 59 ++++--- frontend/src/hooks/useMailboxMaturity.js | 26 ++++ frontend/src/styles.css | 31 +++- frontend/src/utils/mailboxEmptyCopy.js | 99 ++++++++++++ smtp/Dockerfile | 4 +- smtp/entrypoint.sh | 1 + smtp/outbound_header_checks | 2 + 19 files changed, 547 insertions(+), 67 deletions(-) create mode 100644 backend/app/services/welcome_email.py create mode 100644 frontend/src/components/MailboxEmptyIllustration.jsx create mode 100644 frontend/src/components/MailboxEmptyState.jsx create mode 100644 frontend/src/hooks/useMailboxMaturity.js create mode 100644 frontend/src/utils/mailboxEmptyCopy.js create mode 100644 smtp/outbound_header_checks diff --git a/backend/app/config.py b/backend/app/config.py index ada0b14..b5e5832 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 = "" diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 4a173ca..c18eb2e 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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), diff --git a/backend/app/services/email_templates.py b/backend/app/services/email_templates.py index 9bbbca2..41b8921 100644 --- a/backend/app/services/email_templates.py +++ b/backend/app/services/email_templates.py @@ -159,3 +159,72 @@ def build_employee_invitation_email( """ 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""" + +{_escape(subject)} + + + +
+ + +
+

Welcome

+

Welcome to AGMail

+

Hello {safe_name}, your mailbox {safe_email} is ready.

+ {"

Organization: " + safe_org + "

" if organization else ""} +
    +
  • Compose email from your inbox
  • +
  • Use AG Agent for summaries and action items
  • +
  • Review security settings after sign-in
  • +
+ Open AGMail + {"

" + safe_admin + "

" if admin_note else ""} +
+
+""" + + return subject, body_text, body_html diff --git a/backend/app/services/mail_sender.py b/backend/app/services/mail_sender.py index 16e44ff..311fb70 100644 --- a/backend/app/services/mail_sender.py +++ b/backend/app/services/mail_sender.py @@ -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, diff --git a/backend/app/services/welcome_email.py b/backend/app/services/welcome_email.py new file mode 100644 index 0000000..0e581f0 --- /dev/null +++ b/backend/app/services/welcome_email.py @@ -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, + ) diff --git a/backend/tests/test_email_templates.py b/backend/tests/test_email_templates.py index 2ff5975..1625bdb 100644 --- a/backend/tests/test_email_templates.py +++ b/backend/tests/test_email_templates.py @@ -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() diff --git a/backend/tests/test_mail_sender.py b/backend/tests/test_mail_sender.py index 03f5995..87b14ca 100644 --- a/backend/tests/test_mail_sender.py +++ b/backend/tests/test_mail_sender.py @@ -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" diff --git a/deploy/app/docker-compose.yml b/deploy/app/docker-compose.yml index acf738d..6a4cbd6 100644 --- a/deploy/app/docker-compose.yml +++ b/deploy/app/docker-compose.yml @@ -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:-} diff --git a/frontend/src/components/AgAgentPanel.jsx b/frontend/src/components/AgAgentPanel.jsx index 3fd92a0..55d2c45 100644 --- a/frontend/src/components/AgAgentPanel.jsx +++ b/frontend/src/components/AgAgentPanel.jsx @@ -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 && (
{greeting(firstName)} - Here's what needs your attention today. + + {showOnboarding + ? "Your new inbox is ready." + : showQuietEmpty + ? "Nothing needs your attention right now." + : "Here's what needs your attention today."} +
)} @@ -137,16 +152,28 @@ export default function AgAgentPanel({ )} - {!insight && !loading && allMetricsZero && ( + {showOnboarding && ( +
+

+ AGMail is ready +

+

+ No messages yet. I'll start organizing and surfacing important + items as email arrives. +

+
+ )} + + {showQuietEmpty && (

You're all caught up.

-

No urgent messages or outstanding actions.

+

Your inbox is clear for now.

)} - {!insight && !loading && !allMetricsZero && ( + {showFullPanel && !allMetricsZero && (
{cards.map((card) => { const Icon = card.icon; @@ -173,42 +200,91 @@ export default function AgAgentPanel({
)} - {!insight && focus && !allMetricsZero &&

{focus}

} + {showFullPanel && allMetricsZero && ( +
+

+ You're all caught up. +

+

No urgent messages or outstanding actions.

+
+ )} + + {!insight && focus && showFullPanel && !allMetricsZero && ( +

{focus}

+ )}
-

Quick actions

+

{showOnboarding ? "Quick start" : "Quick actions"}

- - - - + {showOnboarding ? ( + <> + + + + + + ) : ( + <> + + + + + + )}
- {!insight && ( + {showFullPanel && !insight && (

Today's agenda

- {loading ? ( - - ) : today.length > 0 ? ( + {today.length > 0 ? (
)} - {!insight && ( + {showFullPanel && !insight && (

Needs attention

- {loading ? ( - - ) : needsReply.length > 0 ? ( + {needsReply.length > 0 ? (
)} - {!insight && deadlines.length > 0 && ( + {showFullPanel && !insight && deadlines.length > 0 && (

Deadlines