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)
|
||||
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 = ""
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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():
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue